Skip to main content

specfold_core/guards/
terms.rs

1//! Denied terms: gathered from the guard, a file outside the repository, or an environment
2//! variable, and matched case-insensitively. Terms are never printed in full.
3
4use crate::config::{Config, GuardDef};
5use anyhow::{Context, Result};
6use regex::{Regex, RegexBuilder};
7
8pub fn load(cfg: &Config, g: &GuardDef) -> Result<Vec<String>> {
9    let mut terms: Vec<String> = g.deny_terms.clone();
10    if let Some(f) = &g.deny_terms_file {
11        let path = cfg.expand_path(f);
12        let text = std::fs::read_to_string(&path)
13            .with_context(|| format!("guard {}: cannot read its terms file {}", g.name, path.display()))?;
14        terms.extend(text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(str::to_string));
15    }
16    if let Some(var) = &g.deny_terms_env
17        && let Ok(v) = std::env::var(var)
18    {
19        terms.extend(v.split(['\n', ',']).map(str::trim).filter(|t| !t.is_empty()).map(str::to_string));
20    }
21    terms.sort();
22    terms.dedup();
23    Ok(terms)
24}
25
26/// One regex for every term, longest first so the most specific term is the one reported.
27pub fn matcher(terms: &[String]) -> Option<Regex> {
28    if terms.is_empty() {
29        return None;
30    }
31    let mut sorted: Vec<&String> = terms.iter().collect();
32    sorted.sort_by_key(|t| std::cmp::Reverse(t.len()));
33    let alt: Vec<String> = sorted.iter().map(|t| regex::escape(t)).collect();
34    RegexBuilder::new(&alt.join("|")).case_insensitive(true).build().ok()
35}
36
37/// `internal-name` → `in***me (13)`: enough to find it, not enough to leak it into a log.
38pub fn mask(term: &str) -> String {
39    let chars: Vec<char> = term.chars().collect();
40    if chars.len() <= 4 {
41        return format!("{}*** ({})", chars.first().copied().unwrap_or('*'), chars.len());
42    }
43    let head: String = chars[..2].iter().collect();
44    let tail: String = chars[chars.len() - 2..].iter().collect();
45    format!("{head}***{tail} ({})", chars.len())
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51
52    #[test]
53    fn matches_and_masks() {
54        let re = matcher(&["acme".into(), "acme-internal".into()]).unwrap();
55        assert_eq!(re.find("see ACME-Internal docs").unwrap().as_str(), "ACME-Internal");
56        assert_eq!(mask("acme-internal"), "ac***al (13)");
57        assert_eq!(mask("abc"), "a*** (3)");
58    }
59}