Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

xtask/tests/threat_model.rs

//! **The threat model is in the tree, names its sections, and every place it cites exists**
//! (OpenSSF silver: `assurance_case` and `documentation_security`; lane OSSF-SILVER-CODE).
//!
//! `docs/THREAT-MODEL.md` is Gunnar's assurance case: the scope, the assets, the trust
//! boundaries, the attackers, the mitigations with the code that holds each one, and the
//! residual risks. A mitigation that cites a file or a function that is no longer there is an
//! argument with a hole in it, and nobody notices the hole by reading the page. So this test
//! reads every citation of the form `` `<path>` `<text>` `` (a repository path, a space, then
//! the symbol or phrase the file must contain) and fails when the file is gone or no longer
//! contains the text. A lone `` `<path>` `` must exist. A path is a backticked token whose
//! first component is an entry at the repository root and that has a `/` or an extension;
//! a crate outside this repository is named by its crate name and version instead, so it is
//! not checked here.
//!
//! The page must also be reachable: `SECURITY.md` and both READMEs (the generated root and
//! its `.nornir/README.md` source) link it.

use std::path::{Path, PathBuf};

const DOC: &str = "docs/THREAT-MODEL.md";

/// The sections, in order. Each is an exact `## ` heading line.
const SECTIONS: &[&str] = &[
    "## Scope",
    "## Assets",
    "## Trust boundaries",
    "## Attackers",
    "## Mitigations",
    "## Residual risks and known gaps",
    "## Reporting a vulnerability",
];

/// The pages that must link the threat model, and the link text they must carry.
const LINKED_FROM: &[(&str, &str)] = &[
    ("SECURITY.md", "(docs/THREAT-MODEL.md)"),
    ("README.md", "(docs/THREAT-MODEL.md)"),
    (".nornir/README.md", "(docs/THREAT-MODEL.md)"),
];

fn root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("the workspace root")
        .to_owned()
}

/// The backticked spans of `line`, in order, with the byte offset just after each one.
fn spans(line: &str) -> Vec<(&str, usize)> {
    let mut out = Vec::new();
    let mut rest = 0;
    while let Some(open) = line[rest..].find('`') {
        let start = rest + open + 1;
        let Some(len) = line[start..].find('`') else {
            break;
        };
        out.push((&line[start..start + len], start + len + 1));
        rest = start + len + 1;
    }
    out
}

/// Is `token` a path into this repository (see the module docs)?
fn is_repo_path(root: &Path, token: &str) -> bool {
    if token.is_empty() || token.contains(char::is_whitespace) || token.starts_with('/') {
        return false;
    }
    let first = token.split('/').next().unwrap_or("");
    let shaped = token.contains('/') || first.contains('.');
    shaped && !first.is_empty() && first != "." && first != ".." && root.join(first).exists()
}

/// Every broken citation in `text`, as `line: what is wrong`.
fn broken_citations(root: &Path, text: &str) -> Vec<String> {
    let mut found = Vec::new();
    let mut fenced = false;
    for (i, line) in text.lines().enumerate() {
        if line.trim_start().starts_with("```") {
            fenced = !fenced;
            continue;
        }
        if fenced {
            continue;
        }
        let spans = spans(line);
        for (k, (token, end)) in spans.iter().enumerate() {
            if !is_repo_path(root, token) {
                continue;
            }
            let path = root.join(token.trim_end_matches('/'));
            if !path.exists() {
                found.push(format!("line {}: `{token}` does not exist", i + 1));
                continue;
            }
            // `<path>` `<text>`: the next span starts one space after this one ends.
            let Some((symbol, _)) = spans.get(k + 1) else {
                continue;
            };
            if !line[*end..].starts_with(" `") || is_repo_path(root, symbol) {
                continue;
            }
            if path.is_dir() {
                found.push(format!(
                    "line {}: `{token}` is a directory, so it cannot contain `{symbol}`",
                    i + 1
                ));
                continue;
            }
            match std::fs::read_to_string(&path) {
                Ok(body) if body.contains(symbol) => {}
                Ok(_) => found.push(format!(
                    "line {}: `{token}` no longer contains `{symbol}`",
                    i + 1
                )),
                Err(e) => found.push(format!("line {}: `{token}`: {e}", i + 1)),
            }
        }
    }
    found
}

fn read(root: &Path, rel: &str) -> String {
    std::fs::read_to_string(root.join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
}

#[test]
fn the_threat_model_names_its_sections_in_order() {
    let root = root();
    let text = read(&root, DOC);
    let headings: Vec<&str> = text.lines().filter(|l| l.starts_with("## ")).collect();
    let mut at = 0;
    for want in SECTIONS {
        match headings[at..].iter().position(|h| h == want) {
            Some(p) => at += p + 1,
            None => panic!("{DOC} has no `{want}` heading after the ones before it; its headings: {headings:#?}"),
        }
    }
}

#[test]
fn every_place_the_threat_model_cites_exists_and_holds_what_it_names() {
    let root = root();
    let text = read(&root, DOC);
    let cited = text
        .lines()
        .flat_map(|l| spans(l).into_iter().map(|(t, _)| t))
        .filter(|t| is_repo_path(&root, t))
        .count();
    assert!(
        cited >= 40,
        "{DOC} cites only {cited} repository paths; the mitigations must name their code"
    );
    let broken = broken_citations(&root, &text);
    assert!(
        broken.is_empty(),
        "{DOC} cites code that is not there:\n{}",
        broken.join("\n")
    );
}

#[test]
fn the_security_policy_and_the_readmes_link_the_threat_model() {
    let root = root();
    for (page, link) in LINKED_FROM {
        assert!(
            read(&root, page).contains(link),
            "{page} does not link {DOC} as `{link}`"
        );
    }
}

/// The detector must be able to go red: a citation of a missing file, and a citation of a
/// real file with text it does not hold, are both reported; a true citation is not.
#[test]
fn a_stale_citation_is_reported_and_a_true_one_is_not() {
    let root = root();
    let good =
        "the guard: `xtask/src/guard.rs` `FORBIDDEN_ANYWHERE`, and `Cargo.toml` `[workspace]`.";
    assert_eq!(broken_citations(&root, good), Vec::<String>::new());

    let missing_file = "see `crates/gunnar-nowhere/src/lib.rs` `anything`";
    let found = broken_citations(&root, missing_file);
    assert!(
        found.len() == 1 && found[0].contains("does not exist"),
        "{found:?}"
    );

    let missing_symbol = "see `xtask/src/guard.rs` `fn_that_was_renamed_away_2026`";
    let found = broken_citations(&root, missing_symbol);
    assert!(
        found.len() == 1 && found[0].contains("no longer contains"),
        "{found:?}"
    );

    // Not repository paths: an outside crate, a ref name, a feature word.
    let outside = "`gunnar-pack 2.0.0` `IndexOptions`, `refs/heads/main`, `online`";
    assert_eq!(broken_citations(&root, outside), Vec::<String>::new());

    // A fenced block is a diagram, not a citation.
    let fenced = "```\n`crates/gunnar-nowhere/x.rs`\n```";
    assert_eq!(broken_citations(&root, fenced), Vec::<String>::new());
}