Skip to main content

specfold_core/
guards.rs

1//! Guards: named checks over any file in the repository and over commit messages, such as the
2//! gate that keeps internal names out of a public package.
3
4mod cache;
5mod code_spans;
6mod compile;
7mod jslex;
8mod pattern;
9mod prefilter;
10mod prove;
11pub mod range;
12mod scan;
13pub mod secrets;
14pub mod terms;
15
16use crate::check::baseline;
17use crate::check::finding::Finding;
18use crate::config::{Config, GuardDef};
19use anyhow::{Result, anyhow, bail};
20pub use range::GuardRange;
21
22fn select<'a>(cfg: &'a Config, names: &[String]) -> Result<Vec<&'a GuardDef>> {
23    if names.is_empty() {
24        return Ok(cfg.raw.guards.iter().collect());
25    }
26    names
27        .iter()
28        .map(|n| {
29            cfg.raw.guards.iter().find(|g| &g.name == n).ok_or_else(|| {
30                let have: Vec<&str> = cfg.raw.guards.iter().map(|g| g.name.as_str()).collect();
31                anyhow!("no guard named '{n}' (have: {})", have.join(", "))
32            })
33        })
34        .collect()
35}
36
37fn scan_one(cfg: &Config, g: &GuardDef, range: Option<&GuardRange>) -> Result<scan::Scanned> {
38    let c = compile::compile(cfg, g)?;
39    prove::prove(g, &c)?;
40    let r = range.cloned().unwrap_or_else(|| GuardRange::parse(&g.range));
41    scan::run(cfg, g, &c, &range::resolve(&cfg.root, &r, &c.bases)?)
42}
43
44/// Runs the named guards (all of them when `names` is empty). A guard without an explicit range
45/// uses its own default; a guard with a baseline reports only hits the baseline does not hold.
46pub fn run(cfg: &Config, names: &[String], range: Option<&GuardRange>) -> Result<Vec<Finding>> {
47    let mut out = vec![];
48    for g in select(cfg, names)? {
49        let found = scan_one(cfg, g, range)?.findings;
50        match &g.baseline {
51            Some(rel) => match baseline::load_file(&cfg.root, rel)? {
52                Some(b) => out.extend(baseline::hold_with(&b, found, &[]).findings),
53                None => out.extend(found),
54            },
55            None => out.extend(found),
56        }
57    }
58    Ok(out)
59}
60
61/// Measures each named guard over the whole tree and writes its baseline file.
62pub fn write_baselines(cfg: &Config, names: &[String]) -> Result<Vec<(String, String, usize)>> {
63    let mut written = vec![];
64    for g in select(cfg, names)? {
65        let Some(rel) = &g.baseline else {
66            if !names.is_empty() {
67                bail!("guard {} has no baseline file; set baseline = \"…\" on it", g.name);
68            }
69            continue;
70        };
71        let scanned = scan_one(cfg, g, Some(&GuardRange::All))?;
72        let b = baseline::measure_with(&cfg.root, &scanned.findings, &[], |p| scanned.files.iter().any(|f| f == p))?;
73        baseline::write_file(&cfg.root, rel, &b)?;
74        written.push((g.name.clone(), rel.clone(), b.counts.values().sum()));
75    }
76    Ok(written)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::config::RawConfig;
83
84    fn config(root: &std::path::Path, guard: &str) -> Config {
85        let raw: RawConfig = toml::from_str(&format!("[[guards]]\n{guard}")).unwrap();
86        Config::from_raw(root.to_path_buf(), raw, false)
87    }
88
89    #[test]
90    fn scans_tree() {
91        let tmp = tempfile::tempdir().unwrap();
92        let r = tmp.path();
93        std::fs::create_dir_all(r.join("libs/open/src")).unwrap();
94        std::fs::create_dir_all(r.join("services/shop")).unwrap();
95        let fake_key = format!("{}{}", "AK", "IA".to_string() + &"X".repeat(16));
96        std::fs::write(r.join("libs/open/src/a.rs"), format!("// built for Acme-Internal\nlet k = \"{fake_key}\";\n"))
97            .unwrap();
98        std::fs::write(r.join("libs/open/.env"), "X=1\n").unwrap();
99        std::fs::write(r.join("services/shop/b.rs"), "acme-internal is fine here\n").unwrap();
100        std::fs::write(r.join("terms.txt"), "# private\nacme-internal\n").unwrap();
101        let cfg = config(
102            r,
103            "name = \"public\"\npaths = [\"libs/open/**\"]\nrange = \"all\"\ndeny_terms_file = \"terms.txt\"\ndeny_files = [\"**/.env*\"]\nsecrets = true\n",
104        );
105        let mut f = run(&cfg, &[], None).unwrap();
106        f.sort_by_key(|a| (a.path.clone(), a.line));
107        let got: Vec<(String, usize)> = f.iter().map(|x| (x.path.clone(), x.line)).collect();
108        assert_eq!(
109            got,
110            [
111                ("libs/open/.env".to_string(), 1),
112                ("libs/open/src/a.rs".to_string(), 1),
113                ("libs/open/src/a.rs".to_string(), 2)
114            ]
115        );
116        assert!(!f[1].message.contains("acme-internal"));
117    }
118
119    #[test]
120    fn patterns_skip_urls_exemptions_and_allowed_text() {
121        let tmp = tempfile::tempdir().unwrap();
122        let r = tmp.path();
123        std::fs::create_dir_all(r.join("pub/docs")).unwrap();
124        std::fs::write(
125            r.join("pub/a.md"),
126            "see https://example.com/internal/x\ninternal/build.sh\nthe internal/README example\n",
127        )
128        .unwrap();
129        std::fs::write(r.join("pub/docs/b.md"), "internal/build.sh\n").unwrap();
130        let cfg = config(
131            r,
132            "name = \"g\"\npaths = [\"pub/**\"]\nrange = \"all\"\nallow = [\"internal/readme\"]\n[[guards.deny_regex]]\nre = 'internal/[a-z.]+'\nlabel = \"internal path\"\nnot_in_url = true\nexempt = [\"pub/docs/**\"]\nmust_hit = [\"run internal/build.sh\"]\nmust_pass = [\"https://example.com/internal/x\"]\n",
133        );
134        let f = run(&cfg, &[], None).unwrap();
135        let got: Vec<(String, usize)> = f.iter().map(|x| (x.path.clone(), x.line)).collect();
136        assert_eq!(got, [("pub/a.md".to_string(), 2)], "{f:?}");
137        assert!(f[0].message.contains("internal path"));
138    }
139
140    #[test]
141    fn a_fixture_that_no_longer_holds_stops_the_run() {
142        let tmp = tempfile::tempdir().unwrap();
143        let cfg = config(
144            tmp.path(),
145            "name = \"g\"\nrange = \"all\"\n[[guards.deny_regex]]\nre = 'secret-[0-9]+'\nmust_hit = [\"secret-abc\"]\n",
146        );
147        let e = run(&cfg, &[], None).unwrap_err().to_string();
148        assert!(e.contains("does not match its must_hit fixture"), "{e}");
149    }
150
151    #[test]
152    fn a_whole_tree_run_over_nothing_fails_and_gzip_is_read() {
153        use std::io::Write;
154        let tmp = tempfile::tempdir().unwrap();
155        let r = tmp.path();
156        let none = config(r, "name = \"g\"\npaths = [\"missing/**\"]\nrange = \"all\"\ndeny_terms = [\"x\"]\n");
157        assert!(run(&none, &[], None).unwrap()[0].message.contains("scanned no file"));
158        std::fs::create_dir_all(r.join("pkg")).unwrap();
159        let mut gz = flate2::write::GzEncoder::new(vec![], flate2::Compression::default());
160        gz.write_all(b"hello acme-internal\n").unwrap();
161        std::fs::write(r.join("pkg/a.txt.gz"), gz.finish().unwrap()).unwrap();
162        let cfg = config(
163            r,
164            "name = \"g\"\npaths = [\"pkg/**\"]\nrange = \"all\"\ndeny_terms = [\"acme-internal\"]\ndecompress = true\n",
165        );
166        assert_eq!(run(&cfg, &[], None).unwrap().len(), 1);
167    }
168
169    #[test]
170    fn an_empty_or_missing_published_path_is_a_hit() {
171        let tmp = tempfile::tempdir().unwrap();
172        let r = tmp.path();
173        std::fs::create_dir_all(r.join("pub/full")).unwrap();
174        std::fs::create_dir_all(r.join("pub/empty")).unwrap();
175        std::fs::write(r.join("pub/full/a.md"), "fine\n").unwrap();
176        let raw: RawConfig = toml::from_str(
177            "[public]\npaths = [\"pub/full\", \"pub/empty\", \"pub/gone\"]\n[[guards]]\nname = \"g\"\npublic = true\nrange = \"all\"\ndeny_terms = [\"x-internal\"]\n",
178        )
179        .unwrap();
180        let cfg = Config::from_raw(r.to_path_buf(), raw, false);
181        let mut got: Vec<(String, bool)> =
182            run(&cfg, &[], None).unwrap().into_iter().map(|f| (f.path, f.message.contains("not present"))).collect();
183        got.sort();
184        assert_eq!(got, [("pub/empty".to_string(), false), ("pub/gone".to_string(), true)]);
185    }
186}