bashkit 0.14.1

Awesomely fast virtual sandbox with bash and file system
Documentation
//! Lint binding code to the threat-model ledger.
//!
//! Every `TM-<CATEGORY>-<NNN>` ID cited anywhere in this crate's source or
//! tests (typically `// THREAT[TM-...]` mitigation anchors and threat-test
//! doc comments) must have an entry in `specs/threat-model.md`. This is the
//! same enforcement direction as `limitations_doc_tests`: code may not cite
//! a ledger entry that does not exist, so restructuring the ledger can never
//! silently orphan a mitigation anchor.
//!
//! A second test keeps the user-facing threat model
//! (`crates/bashkit/docs/threat-model.md`) in one-to-one sync with the
//! canonical ledger (`specs/threat-model.md`): every spec threat must be
//! documented publicly, and the public doc may not invent IDs the ledger
//! lacks. This closes the drift tracked in #2155.

use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};

fn extract_tm_ids(text: &str, found: &mut HashSet<String>) {
    let bytes = text.as_bytes();
    let mut i = 0;
    while let Some(off) = text[i..].find("TM-") {
        let start = i + off;
        let rest = &text[start + 3..];
        let cat: String = rest
            .chars()
            .take_while(|c| c.is_ascii_uppercase())
            .collect();
        let after_cat = &rest[cat.len()..];
        if !cat.is_empty() && after_cat.starts_with('-') {
            let num: String = after_cat[1..]
                .chars()
                .take_while(|c| c.is_ascii_digit())
                .collect();
            if !num.is_empty() {
                found.insert(format!("TM-{cat}-{num}"));
                // Shorthand group citations: TM-ISO-005/006/007 cites three
                // IDs in the same category. Expand each /NNN suffix.
                let mut tail = &after_cat[1 + num.len()..];
                while let Some(stripped) = tail.strip_prefix('/') {
                    let next: String = stripped
                        .chars()
                        .take_while(|c| c.is_ascii_digit())
                        .collect();
                    if next.is_empty() {
                        break;
                    }
                    found.insert(format!("TM-{cat}-{next}"));
                    tail = &stripped[next.len()..];
                }
            }
        }
        i = start + 3;
        if i >= bytes.len() {
            break;
        }
    }
}

fn walk_rs_files(root: &Path, out: &mut Vec<PathBuf>) {
    for entry in std::fs::read_dir(root).unwrap() {
        let path = entry.unwrap().path();
        if path.is_dir() {
            walk_rs_files(&path, out);
        } else if path.extension().is_some_and(|e| e == "rs") {
            out.push(path);
        }
    }
}

#[test]
fn threat_ids_cited_in_code_exist_in_threat_model_doc() {
    let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
    let doc_path = manifest.join("../../specs/threat-model.md");
    let doc = std::fs::read_to_string(&doc_path)
        .unwrap_or_else(|e| panic!("read {}: {e}", doc_path.display()));

    let mut doc_ids = HashSet::new();
    extract_tm_ids(&doc, &mut doc_ids);
    assert!(
        doc_ids.len() > 100,
        "suspiciously few TM IDs in threat-model.md ({}) — parsing broken?",
        doc_ids.len()
    );

    let mut files = Vec::new();
    walk_rs_files(&manifest.join("src"), &mut files);
    walk_rs_files(&manifest.join("tests"), &mut files);

    // id -> first file that cites it, for actionable failure output
    let mut cited: BTreeMap<String, PathBuf> = BTreeMap::new();
    for file in files {
        let text = std::fs::read_to_string(&file).unwrap();
        let mut ids = HashSet::new();
        extract_tm_ids(&text, &mut ids);
        for id in ids {
            cited.entry(id).or_insert_with(|| file.clone());
        }
    }

    let missing: Vec<String> = cited
        .iter()
        .filter(|(id, _)| !doc_ids.contains(*id))
        .map(|(id, file)| format!("{id} (cited in {})", file.display()))
        .collect();
    assert!(
        missing.is_empty(),
        "TM IDs cited in code but missing from specs/threat-model.md:\n{}",
        missing.join("\n")
    );
}

#[test]
fn public_threat_model_doc_covers_every_spec_threat_id() {
    let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
    let spec_path = manifest.join("../../specs/threat-model.md");
    let public_path = manifest.join("docs/threat-model.md");
    let spec = std::fs::read_to_string(&spec_path)
        .unwrap_or_else(|e| panic!("read {}: {e}", spec_path.display()));
    let public = std::fs::read_to_string(&public_path)
        .unwrap_or_else(|e| panic!("read {}: {e}", public_path.display()));

    let mut spec_ids = HashSet::new();
    extract_tm_ids(&spec, &mut spec_ids);
    let mut public_ids = HashSet::new();
    extract_tm_ids(&public, &mut public_ids);

    assert!(
        spec_ids.len() > 100,
        "suspiciously few TM IDs in specs/threat-model.md ({}) — parsing broken?",
        spec_ids.len()
    );

    // Every threat in the canonical ledger must appear in the user-facing doc.
    let mut missing: Vec<&str> = spec_ids
        .difference(&public_ids)
        .map(String::as_str)
        .collect();
    missing.sort_unstable();
    assert!(
        missing.is_empty(),
        "{} TM ID(s) in specs/threat-model.md but missing from \
         crates/bashkit/docs/threat-model.md — port them (see #2155):\n{}",
        missing.len(),
        missing.join("\n")
    );

    // ...and the public doc must not invent IDs the ledger lacks (typo guard).
    let mut stray: Vec<&str> = public_ids
        .difference(&spec_ids)
        .map(String::as_str)
        .collect();
    stray.sort_unstable();
    assert!(
        stray.is_empty(),
        "{} TM ID(s) in crates/bashkit/docs/threat-model.md but absent from \
         specs/threat-model.md — fix the ID or add the ledger entry:\n{}",
        stray.len(),
        stray.join("\n")
    );
}