use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
pub const DENYLIST_PATH: &str = ".kranz/domain-denylist.json";
pub const ALLOWLIST_PATH: &str = ".kranz/domain-allowlist";
pub const TERMS_LOCAL_PATH: &str = ".kranz/domain-terms.local";
pub const MAX_TERM_TOKENS: usize = 8;
const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
const CONFIG_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomainFinding {
pub fingerprint: String,
pub path: String,
pub line: usize,
}
#[derive(Debug, Clone)]
pub struct Denylist {
salt: String,
hashes: BTreeSet<String>,
}
impl Denylist {
pub fn term_count(&self) -> usize {
self.hashes.len()
}
}
#[derive(Debug, Clone, Default)]
pub struct LintReport {
pub findings: Vec<DomainFinding>,
pub files_scanned: usize,
pub files_skipped: usize,
}
impl LintReport {
pub fn is_clean(&self) -> bool {
self.findings.is_empty()
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DenylistConfig {
version: u32,
salt: String,
hash: String,
terms: BTreeSet<String>,
}
const HASH_SCHEME: &str = "sha256(salt || NUL || normalized-term)";
pub fn normalize_term(text: &str) -> String {
tokens(text)
.into_iter()
.map(|(token, _)| token)
.collect::<Vec<_>>()
.join(" ")
}
fn tokens(text: &str) -> Vec<(String, usize)> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i].is_ascii_alphanumeric() {
let start = i;
while i < bytes.len() && bytes[i].is_ascii_alphanumeric() {
i += 1;
}
out.push((text[start..i].to_ascii_lowercase(), start));
} else {
i += 1;
}
}
out
}
fn salted_hash(salt: &str, input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(salt.as_bytes());
hasher.update([0]);
hasher.update(input.as_bytes());
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
pub fn waiver_fingerprint(salt: &str, normalized_term: &str, path: &str) -> String {
let full = salted_hash(salt, &format!("{normalized_term}\0{path}"));
full[..24].to_string()
}
fn is_64_hex(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}
pub fn load_denylist(text: &str) -> Result<Denylist> {
let config: DenylistConfig =
serde_json::from_str(text).context("domain denylist config is not valid JSON")?;
if config.version != CONFIG_VERSION {
bail!(
"domain denylist config version {} is unsupported (expected {CONFIG_VERSION})",
config.version
);
}
if !is_64_hex(&config.salt) {
bail!("domain denylist salt must be 64 lowercase hex characters");
}
if config.hash != HASH_SCHEME {
bail!("domain denylist hash scheme is not the documented one");
}
if config.terms.is_empty() {
bail!("domain denylist has no terms — an empty denylist is a vacuous guard");
}
for term in &config.terms {
if !is_64_hex(term) {
bail!("domain denylist entries must be 64 lowercase hex (salted hashes only)");
}
}
Ok(Denylist {
salt: config.salt,
hashes: config.terms,
})
}
pub fn seed_config(existing_config: Option<&str>, terms_text: &str) -> Result<String> {
let salt = match existing_config {
Some(existing) => {
load_denylist(existing)
.context("existing denylist config cannot be reseeded")?
.salt
}
None => format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
),
};
let mut hashes = BTreeSet::new();
for (index, line) in terms_text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let normalized = normalize_term(line);
if normalized.is_empty() {
continue;
}
let count = normalized.split(' ').count();
if count > MAX_TERM_TOKENS {
bail!(
"terms file line {} normalizes to {count} tokens (max {MAX_TERM_TOKENS})",
index + 1
);
}
hashes.insert(salted_hash(&salt, &normalized));
}
if hashes.is_empty() {
bail!("terms file yielded no terms — refusing to seed an empty (vacuous) denylist");
}
let config = DenylistConfig {
version: CONFIG_VERSION,
salt,
hash: HASH_SCHEME.to_string(),
terms: hashes,
};
Ok(serde_json::to_string_pretty(&config)? + "\n")
}
pub fn is_excluded_path(path: &Path) -> bool {
let path = path.to_string_lossy();
let path = path.strip_prefix("./").unwrap_or(&path);
path.starts_with(".kranz/missions/")
|| path == DENYLIST_PATH
|| path == ALLOWLIST_PATH
|| path == TERMS_LOCAL_PATH
}
pub fn enumerate_scoped_files(repo_root: &Path) -> Result<Vec<PathBuf>> {
let out = std::process::Command::new("git")
.args([
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
])
.current_dir(repo_root)
.output()
.context("spawn git ls-files")?;
if !out.status.success() {
return Err(anyhow!(
"git ls-files failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
let mut paths: Vec<PathBuf> = String::from_utf8_lossy(&out.stdout)
.split('\0')
.filter(|entry| !entry.is_empty())
.map(PathBuf::from)
.filter(|path| !is_excluded_path(path))
.collect();
paths.sort();
Ok(paths)
}
fn line_starts(text: &str) -> Vec<usize> {
let mut starts = vec![0];
for (index, byte) in text.bytes().enumerate() {
if byte == b'\n' {
starts.push(index + 1);
}
}
starts
}
fn scan_text(denylist: &Denylist, path: &str, text: &str, findings: &mut Vec<DomainFinding>) {
let tokens = tokens(text);
let starts = line_starts(text);
for start in 0..tokens.len() {
let end = (start + MAX_TERM_TOKENS).min(tokens.len());
let mut ngram = String::new();
for (token, _) in &tokens[start..end] {
if !ngram.is_empty() {
ngram.push(' ');
}
ngram.push_str(token);
if denylist
.hashes
.contains(&salted_hash(&denylist.salt, &ngram))
{
let line = starts.partition_point(|offset| *offset <= tokens[start].1);
findings.push(DomainFinding {
fingerprint: waiver_fingerprint(&denylist.salt, &ngram, path),
path: path.to_string(),
line,
});
}
}
}
}
pub fn lint_files(repo_root: &Path, paths: &[PathBuf], denylist: &Denylist) -> LintReport {
let mut report = LintReport::default();
for path in paths {
if is_excluded_path(path) {
continue;
}
let full = repo_root.join(path);
let Ok(metadata) = std::fs::symlink_metadata(&full) else {
report.files_skipped += 1;
continue;
};
if !metadata.is_file() || metadata.len() > MAX_FILE_BYTES {
report.files_skipped += 1;
continue;
}
let Ok(bytes) = std::fs::read(&full) else {
report.files_skipped += 1;
continue;
};
if bytes.contains(&0) {
report.files_skipped += 1;
continue;
}
let text = String::from_utf8_lossy(&bytes);
let path_str = path.to_string_lossy().replace('\\', "/");
scan_text(denylist, &path_str, &text, &mut report.findings);
report.files_scanned += 1;
}
report
}
pub fn filter_allowed(
findings: Vec<DomainFinding>,
allowed: &BTreeSet<String>,
) -> Vec<DomainFinding> {
findings
.into_iter()
.filter(|finding| !allowed.contains(&finding.fingerprint))
.collect()
}
pub fn lint_tree(
repo_root: &Path,
denylist: &Denylist,
allowed: &BTreeSet<String>,
) -> Result<LintReport> {
let paths = enumerate_scoped_files(repo_root)?;
let mut report = lint_files(repo_root, &paths, denylist);
report.findings = filter_allowed(report.findings, allowed);
report
.findings
.sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_TERMS: &str = "# synthetic fixture vocabulary, never real\n\
zz-acme lint canary\n\
zz other term\n";
fn test_denylist() -> Denylist {
let config = seed_config(None, TEST_TERMS).expect("seed test config");
load_denylist(&config).expect("parse seeded config")
}
#[test]
fn domain_lint_normalize_collapses_case_spacing_and_punctuation() {
assert_eq!(normalize_term("ZZ Acme—Corp!"), "zz acme corp");
assert_eq!(normalize_term("zz-acme"), normalize_term("ZZ ACME"));
assert_eq!(normalize_term(" x.y "), "x y");
assert_eq!(normalize_term("zzAcme"), "zzacme");
assert_eq!(normalize_term("café"), "caf");
}
#[test]
fn domain_lint_seed_config_emits_hashes_only_and_roundtrips() {
let config = seed_config(None, TEST_TERMS).expect("seed");
for fragment in ["zz", "acme", "canary", "lint"] {
assert!(
!config.contains(fragment),
"seeded config leaks plaintext fragment {fragment:?}: {config}"
);
}
let value: serde_json::Value = serde_json::from_str(&config).expect("valid json");
let keys: Vec<&str> = value
.as_object()
.expect("object")
.keys()
.map(String::as_str)
.collect();
assert_eq!(keys, ["hash", "salt", "terms", "version"]);
let denylist = load_denylist(&config).expect("load");
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("f.md"),
"mentions the ZZ-Acme lint canary\n",
)
.unwrap();
let report = lint_files(dir.path(), &[PathBuf::from("f.md")], &denylist);
assert_eq!(report.findings.len(), 1, "{report:?}");
assert_eq!(report.findings[0].line, 1);
}
#[test]
fn domain_lint_seeded_term_trips_naming_file_and_line_then_removal_goes_green() {
let denylist = test_denylist();
let dir = tempfile::tempdir().unwrap();
let path = PathBuf::from("docs/scratch-fixture.md");
std::fs::create_dir_all(dir.path().join("docs")).unwrap();
std::fs::write(
dir.path().join(&path),
"line one\nsecond line plants the zz acme lint canary here\nthird\n",
)
.unwrap();
let report = lint_files(dir.path(), std::slice::from_ref(&path), &denylist);
assert_eq!(report.findings.len(), 1, "{report:?}");
assert_eq!(report.findings[0].path, "docs/scratch-fixture.md");
assert_eq!(report.findings[0].line, 2, "{report:?}");
std::fs::write(dir.path().join(&path), "line one\nsecond line\nthird\n").unwrap();
let report = lint_files(dir.path(), &[path], &denylist);
assert!(report.is_clean(), "{report:?}");
}
#[test]
fn domain_lint_phrase_spanning_a_line_break_matches_at_first_token_line() {
let denylist = test_denylist();
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("wrap.md"),
"one\nplants the zz\nacme lint canary here\n",
)
.unwrap();
let report = lint_files(dir.path(), &[PathBuf::from("wrap.md")], &denylist);
assert_eq!(report.findings.len(), 1, "{report:?}");
assert_eq!(report.findings[0].line, 2, "{report:?}");
}
#[test]
fn domain_lint_waiver_suppresses_only_the_waived_path() {
let denylist = test_denylist();
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.md"), "zz acme lint canary\n").unwrap();
std::fs::write(dir.path().join("b.md"), "zz acme lint canary\n").unwrap();
let paths = vec![PathBuf::from("a.md"), PathBuf::from("b.md")];
let report = lint_files(dir.path(), &paths, &denylist);
assert_eq!(report.findings.len(), 2);
let waiver: BTreeSet<String> = report
.findings
.iter()
.filter(|f| f.path == "a.md")
.map(|f| f.fingerprint.clone())
.collect();
let remaining = filter_allowed(report.findings, &waiver);
assert_eq!(remaining.len(), 1, "{remaining:?}");
assert_eq!(remaining[0].path, "b.md", "a waiver is path-scoped");
}
#[test]
fn domain_lint_load_rejects_non_hash_form_configs() {
for bad in [
r#"{"version":1,"salt":"aaaa","hash":"sha256(salt || NUL || normalized-term)","terms":["ab"]}"#,
r#"{"version":1,"salt":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash":"sha256(salt || NUL || normalized-term)","terms":["zz acme"]}"#,
r#"{"version":2,"salt":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash":"sha256(salt || NUL || normalized-term)","terms":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#,
r#"{"version":1,"salt":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash":"sha256(salt || NUL || normalized-term)","terms":[]}"#,
] {
assert!(load_denylist(bad).is_err(), "accepted {bad}");
}
}
#[test]
fn domain_lint_seed_refuses_overlong_terms_and_empty_inputs() {
let long = (0..=MAX_TERM_TOKENS)
.map(|i| format!("t{i}"))
.collect::<Vec<_>>()
.join(" ");
assert!(seed_config(None, &long).is_err(), "overlong term seeded");
assert!(seed_config(None, "# only comments\n\n").is_err());
}
#[test]
fn domain_lint_seed_preserves_salt_across_reseeds() {
let first = seed_config(None, "zz one\n").unwrap();
let second = seed_config(Some(&first), "zz one\nzz two\n").unwrap();
let first: serde_json::Value = serde_json::from_str(&first).unwrap();
let second: serde_json::Value = serde_json::from_str(&second).unwrap();
assert_eq!(first["salt"], second["salt"], "reseed must keep the salt");
assert_eq!(second["terms"].as_array().unwrap().len(), 2);
}
#[test]
fn domain_lint_excluded_paths_cover_missions_and_own_config() {
assert!(is_excluded_path(Path::new(".kranz/missions/m-zz/plan.md")));
assert!(is_excluded_path(Path::new(DENYLIST_PATH)));
assert!(is_excluded_path(Path::new(ALLOWLIST_PATH)));
assert!(is_excluded_path(Path::new(TERMS_LOCAL_PATH)));
assert!(!is_excluded_path(Path::new("crates/engine/src/lib.rs")));
assert!(!is_excluded_path(Path::new(
".kranz/tickets/some-ticket.md"
)));
}
#[test]
fn domain_lint_committed_config_is_hash_form_only() {
let committed = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../.kranz/domain-denylist.json"
);
let text = std::fs::read_to_string(committed)
.unwrap_or_else(|err| panic!("read {committed}: {err}"));
let value: serde_json::Value =
serde_json::from_str(&text).expect("committed config parses");
let object = value.as_object().expect("object");
for key in object.keys() {
assert!(
matches!(key.as_str(), "version" | "salt" | "hash" | "terms"),
"unexpected key {key:?} — nowhere for readable terms to hide"
);
}
let denylist = load_denylist(&text).expect("committed config validates");
assert!(denylist.hashes.len() >= 3, "committed denylist is seeded");
let synthetic = salted_hash(&denylist.salt, "zz acme lint canary");
assert!(
!denylist.hashes.contains(&synthetic),
"synthetic test vocabulary must never seed the real config"
);
}
}