1use anyhow::{anyhow, bail, Context, Result};
79use serde::{Deserialize, Serialize};
80use sha2::{Digest, Sha256};
81use std::collections::BTreeSet;
82use std::path::{Path, PathBuf};
83
84pub const DENYLIST_PATH: &str = ".kranz/domain-denylist.json";
86
87pub const ALLOWLIST_PATH: &str = ".kranz/domain-allowlist";
90
91pub const TERMS_LOCAL_PATH: &str = ".kranz/domain-terms.local";
95
96pub const MAX_TERM_TOKENS: usize = 8;
99
100const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
103
104const CONFIG_VERSION: u32 = 1;
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct DomainFinding {
114 pub fingerprint: String,
115 pub path: String,
116 pub line: usize,
117}
118
119#[derive(Debug, Clone)]
121pub struct Denylist {
122 salt: String,
123 hashes: BTreeSet<String>,
124}
125
126impl Denylist {
127 pub fn term_count(&self) -> usize {
130 self.hashes.len()
131 }
132}
133
134#[derive(Debug, Clone, Default)]
137pub struct LintReport {
138 pub findings: Vec<DomainFinding>,
139 pub files_scanned: usize,
140 pub files_skipped: usize,
141}
142
143impl LintReport {
144 pub fn is_clean(&self) -> bool {
145 self.findings.is_empty()
146 }
147}
148
149#[derive(Debug, Serialize, Deserialize)]
152#[serde(rename_all = "camelCase")]
153struct DenylistConfig {
154 version: u32,
155 salt: String,
156 hash: String,
157 terms: BTreeSet<String>,
158}
159
160const HASH_SCHEME: &str = "sha256(salt || NUL || normalized-term)";
161
162pub fn normalize_term(text: &str) -> String {
165 tokens(text)
166 .into_iter()
167 .map(|(token, _)| token)
168 .collect::<Vec<_>>()
169 .join(" ")
170}
171
172fn tokens(text: &str) -> Vec<(String, usize)> {
176 let mut out = Vec::new();
177 let bytes = text.as_bytes();
178 let mut i = 0;
179 while i < bytes.len() {
180 if bytes[i].is_ascii_alphanumeric() {
181 let start = i;
182 while i < bytes.len() && bytes[i].is_ascii_alphanumeric() {
183 i += 1;
184 }
185 out.push((text[start..i].to_ascii_lowercase(), start));
186 } else {
187 i += 1;
188 }
189 }
190 out
191}
192
193fn salted_hash(salt: &str, input: &str) -> String {
195 let mut hasher = Sha256::new();
196 hasher.update(salt.as_bytes());
197 hasher.update([0]);
198 hasher.update(input.as_bytes());
199 hasher
200 .finalize()
201 .iter()
202 .map(|b| format!("{b:02x}"))
203 .collect()
204}
205
206pub fn waiver_fingerprint(salt: &str, normalized_term: &str, path: &str) -> String {
209 let full = salted_hash(salt, &format!("{normalized_term}\0{path}"));
210 full[..24].to_string()
211}
212
213fn is_64_hex(value: &str) -> bool {
214 value.len() == 64
215 && value
216 .bytes()
217 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
218}
219
220pub fn load_denylist(text: &str) -> Result<Denylist> {
226 let config: DenylistConfig =
227 serde_json::from_str(text).context("domain denylist config is not valid JSON")?;
228 if config.version != CONFIG_VERSION {
229 bail!(
230 "domain denylist config version {} is unsupported (expected {CONFIG_VERSION})",
231 config.version
232 );
233 }
234 if !is_64_hex(&config.salt) {
235 bail!("domain denylist salt must be 64 lowercase hex characters");
236 }
237 if config.hash != HASH_SCHEME {
238 bail!("domain denylist hash scheme is not the documented one");
239 }
240 if config.terms.is_empty() {
241 bail!("domain denylist has no terms — an empty denylist is a vacuous guard");
242 }
243 for term in &config.terms {
244 if !is_64_hex(term) {
245 bail!("domain denylist entries must be 64 lowercase hex (salted hashes only)");
246 }
247 }
248 Ok(Denylist {
249 salt: config.salt,
250 hashes: config.terms,
251 })
252}
253
254pub fn seed_config(existing_config: Option<&str>, terms_text: &str) -> Result<String> {
260 let salt = match existing_config {
261 Some(existing) => {
262 load_denylist(existing)
263 .context("existing denylist config cannot be reseeded")?
264 .salt
265 }
266 None => format!(
267 "{}{}",
268 uuid::Uuid::new_v4().simple(),
269 uuid::Uuid::new_v4().simple()
270 ),
271 };
272
273 let mut hashes = BTreeSet::new();
274 for (index, line) in terms_text.lines().enumerate() {
275 let line = line.trim();
276 if line.is_empty() || line.starts_with('#') {
277 continue;
278 }
279 let normalized = normalize_term(line);
280 if normalized.is_empty() {
281 continue;
284 }
285 let count = normalized.split(' ').count();
286 if count > MAX_TERM_TOKENS {
287 bail!(
289 "terms file line {} normalizes to {count} tokens (max {MAX_TERM_TOKENS})",
290 index + 1
291 );
292 }
293 hashes.insert(salted_hash(&salt, &normalized));
294 }
295 if hashes.is_empty() {
296 bail!("terms file yielded no terms — refusing to seed an empty (vacuous) denylist");
297 }
298
299 let config = DenylistConfig {
300 version: CONFIG_VERSION,
301 salt,
302 hash: HASH_SCHEME.to_string(),
303 terms: hashes,
304 };
305 Ok(serde_json::to_string_pretty(&config)? + "\n")
306}
307
308pub fn is_excluded_path(path: &Path) -> bool {
312 let path = path.to_string_lossy();
313 let path = path.strip_prefix("./").unwrap_or(&path);
314 path.starts_with(".kranz/missions/")
315 || path == DENYLIST_PATH
316 || path == ALLOWLIST_PATH
317 || path == TERMS_LOCAL_PATH
318}
319
320pub fn enumerate_scoped_files(repo_root: &Path) -> Result<Vec<PathBuf>> {
326 let out = std::process::Command::new("git")
330 .args([
331 "ls-files",
332 "-z",
333 "--cached",
334 "--others",
335 "--exclude-standard",
336 ])
337 .current_dir(repo_root)
338 .output()
339 .context("spawn git ls-files")?;
340 if !out.status.success() {
341 return Err(anyhow!(
342 "git ls-files failed: {}",
343 String::from_utf8_lossy(&out.stderr).trim()
344 ));
345 }
346 let mut paths: Vec<PathBuf> = String::from_utf8_lossy(&out.stdout)
347 .split('\0')
348 .filter(|entry| !entry.is_empty())
349 .map(PathBuf::from)
350 .filter(|path| !is_excluded_path(path))
351 .collect();
352 paths.sort();
353 Ok(paths)
354}
355
356fn line_starts(text: &str) -> Vec<usize> {
358 let mut starts = vec![0];
359 for (index, byte) in text.bytes().enumerate() {
360 if byte == b'\n' {
361 starts.push(index + 1);
362 }
363 }
364 starts
365}
366
367fn scan_text(denylist: &Denylist, path: &str, text: &str, findings: &mut Vec<DomainFinding>) {
372 let tokens = tokens(text);
373 let starts = line_starts(text);
374 for start in 0..tokens.len() {
375 let end = (start + MAX_TERM_TOKENS).min(tokens.len());
376 let mut ngram = String::new();
377 for (token, _) in &tokens[start..end] {
378 if !ngram.is_empty() {
379 ngram.push(' ');
380 }
381 ngram.push_str(token);
382 if denylist
383 .hashes
384 .contains(&salted_hash(&denylist.salt, &ngram))
385 {
386 let line = starts.partition_point(|offset| *offset <= tokens[start].1);
387 findings.push(DomainFinding {
388 fingerprint: waiver_fingerprint(&denylist.salt, &ngram, path),
389 path: path.to_string(),
390 line,
391 });
392 }
393 }
394 }
395}
396
397pub fn lint_files(repo_root: &Path, paths: &[PathBuf], denylist: &Denylist) -> LintReport {
403 let mut report = LintReport::default();
404 for path in paths {
405 if is_excluded_path(path) {
406 continue;
407 }
408 let full = repo_root.join(path);
409 let Ok(metadata) = std::fs::symlink_metadata(&full) else {
414 report.files_skipped += 1;
415 continue;
416 };
417 if !metadata.is_file() || metadata.len() > MAX_FILE_BYTES {
418 report.files_skipped += 1;
419 continue;
420 }
421 let Ok(bytes) = std::fs::read(&full) else {
422 report.files_skipped += 1;
423 continue;
424 };
425 if bytes.contains(&0) {
427 report.files_skipped += 1;
428 continue;
429 }
430 let text = String::from_utf8_lossy(&bytes);
431 let path_str = path.to_string_lossy().replace('\\', "/");
432 scan_text(denylist, &path_str, &text, &mut report.findings);
433 report.files_scanned += 1;
434 }
435 report
436}
437
438pub fn filter_allowed(
442 findings: Vec<DomainFinding>,
443 allowed: &BTreeSet<String>,
444) -> Vec<DomainFinding> {
445 findings
446 .into_iter()
447 .filter(|finding| !allowed.contains(&finding.fingerprint))
448 .collect()
449}
450
451pub fn lint_tree(
453 repo_root: &Path,
454 denylist: &Denylist,
455 allowed: &BTreeSet<String>,
456) -> Result<LintReport> {
457 let paths = enumerate_scoped_files(repo_root)?;
458 let mut report = lint_files(repo_root, &paths, denylist);
459 report.findings = filter_allowed(report.findings, allowed);
460 report
462 .findings
463 .sort_by(|a, b| (&a.path, a.line, &a.fingerprint).cmp(&(&b.path, b.line, &b.fingerprint)));
464 Ok(report)
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 const TEST_TERMS: &str = "# synthetic fixture vocabulary, never real\n\
474 zz-acme lint canary\n\
475 zz other term\n";
476
477 fn test_denylist() -> Denylist {
478 let config = seed_config(None, TEST_TERMS).expect("seed test config");
479 load_denylist(&config).expect("parse seeded config")
480 }
481
482 #[test]
483 fn domain_lint_normalize_collapses_case_spacing_and_punctuation() {
484 assert_eq!(normalize_term("ZZ Acme—Corp!"), "zz acme corp");
485 assert_eq!(normalize_term("zz-acme"), normalize_term("ZZ ACME"));
486 assert_eq!(normalize_term(" x.y "), "x y");
487 assert_eq!(normalize_term("zzAcme"), "zzacme");
489 assert_eq!(normalize_term("café"), "caf");
491 }
492
493 #[test]
494 fn domain_lint_seed_config_emits_hashes_only_and_roundtrips() {
495 let config = seed_config(None, TEST_TERMS).expect("seed");
496 for fragment in ["zz", "acme", "canary", "lint"] {
498 assert!(
499 !config.contains(fragment),
500 "seeded config leaks plaintext fragment {fragment:?}: {config}"
501 );
502 }
503 let value: serde_json::Value = serde_json::from_str(&config).expect("valid json");
504 let keys: Vec<&str> = value
505 .as_object()
506 .expect("object")
507 .keys()
508 .map(String::as_str)
509 .collect();
510 assert_eq!(keys, ["hash", "salt", "terms", "version"]);
511 let denylist = load_denylist(&config).expect("load");
513 let dir = tempfile::tempdir().unwrap();
514 std::fs::write(
515 dir.path().join("f.md"),
516 "mentions the ZZ-Acme lint canary\n",
517 )
518 .unwrap();
519 let report = lint_files(dir.path(), &[PathBuf::from("f.md")], &denylist);
520 assert_eq!(report.findings.len(), 1, "{report:?}");
521 assert_eq!(report.findings[0].line, 1);
522 }
523
524 #[test]
525 fn domain_lint_seeded_term_trips_naming_file_and_line_then_removal_goes_green() {
526 let denylist = test_denylist();
527 let dir = tempfile::tempdir().unwrap();
528 let path = PathBuf::from("docs/scratch-fixture.md");
529 std::fs::create_dir_all(dir.path().join("docs")).unwrap();
530 std::fs::write(
531 dir.path().join(&path),
532 "line one\nsecond line plants the zz acme lint canary here\nthird\n",
533 )
534 .unwrap();
535
536 let report = lint_files(dir.path(), std::slice::from_ref(&path), &denylist);
537 assert_eq!(report.findings.len(), 1, "{report:?}");
538 assert_eq!(report.findings[0].path, "docs/scratch-fixture.md");
539 assert_eq!(report.findings[0].line, 2, "{report:?}");
540
541 std::fs::write(dir.path().join(&path), "line one\nsecond line\nthird\n").unwrap();
543 let report = lint_files(dir.path(), &[path], &denylist);
544 assert!(report.is_clean(), "{report:?}");
545 }
546
547 #[test]
548 fn domain_lint_phrase_spanning_a_line_break_matches_at_first_token_line() {
549 let denylist = test_denylist();
550 let dir = tempfile::tempdir().unwrap();
551 std::fs::write(
553 dir.path().join("wrap.md"),
554 "one\nplants the zz\nacme lint canary here\n",
555 )
556 .unwrap();
557 let report = lint_files(dir.path(), &[PathBuf::from("wrap.md")], &denylist);
558 assert_eq!(report.findings.len(), 1, "{report:?}");
559 assert_eq!(report.findings[0].line, 2, "{report:?}");
560 }
561
562 #[test]
563 fn domain_lint_waiver_suppresses_only_the_waived_path() {
564 let denylist = test_denylist();
565 let dir = tempfile::tempdir().unwrap();
566 std::fs::write(dir.path().join("a.md"), "zz acme lint canary\n").unwrap();
567 std::fs::write(dir.path().join("b.md"), "zz acme lint canary\n").unwrap();
568 let paths = vec![PathBuf::from("a.md"), PathBuf::from("b.md")];
569
570 let report = lint_files(dir.path(), &paths, &denylist);
571 assert_eq!(report.findings.len(), 2);
572 let waiver: BTreeSet<String> = report
574 .findings
575 .iter()
576 .filter(|f| f.path == "a.md")
577 .map(|f| f.fingerprint.clone())
578 .collect();
579 let remaining = filter_allowed(report.findings, &waiver);
580 assert_eq!(remaining.len(), 1, "{remaining:?}");
581 assert_eq!(remaining[0].path, "b.md", "a waiver is path-scoped");
582 }
583
584 #[test]
585 fn domain_lint_load_rejects_non_hash_form_configs() {
586 for bad in [
588 r#"{"version":1,"salt":"aaaa","hash":"sha256(salt || NUL || normalized-term)","terms":["ab"]}"#,
589 r#"{"version":1,"salt":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash":"sha256(salt || NUL || normalized-term)","terms":["zz acme"]}"#,
590 r#"{"version":2,"salt":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash":"sha256(salt || NUL || normalized-term)","terms":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#,
591 r#"{"version":1,"salt":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","hash":"sha256(salt || NUL || normalized-term)","terms":[]}"#,
592 ] {
593 assert!(load_denylist(bad).is_err(), "accepted {bad}");
594 }
595 }
596
597 #[test]
598 fn domain_lint_seed_refuses_overlong_terms_and_empty_inputs() {
599 let long = (0..=MAX_TERM_TOKENS)
600 .map(|i| format!("t{i}"))
601 .collect::<Vec<_>>()
602 .join(" ");
603 assert!(seed_config(None, &long).is_err(), "overlong term seeded");
604 assert!(seed_config(None, "# only comments\n\n").is_err());
605 }
606
607 #[test]
608 fn domain_lint_seed_preserves_salt_across_reseeds() {
609 let first = seed_config(None, "zz one\n").unwrap();
610 let second = seed_config(Some(&first), "zz one\nzz two\n").unwrap();
611 let first: serde_json::Value = serde_json::from_str(&first).unwrap();
612 let second: serde_json::Value = serde_json::from_str(&second).unwrap();
613 assert_eq!(first["salt"], second["salt"], "reseed must keep the salt");
614 assert_eq!(second["terms"].as_array().unwrap().len(), 2);
615 }
616
617 #[test]
618 fn domain_lint_excluded_paths_cover_missions_and_own_config() {
619 assert!(is_excluded_path(Path::new(".kranz/missions/m-zz/plan.md")));
620 assert!(is_excluded_path(Path::new(DENYLIST_PATH)));
621 assert!(is_excluded_path(Path::new(ALLOWLIST_PATH)));
622 assert!(is_excluded_path(Path::new(TERMS_LOCAL_PATH)));
623 assert!(!is_excluded_path(Path::new("crates/engine/src/lib.rs")));
624 assert!(!is_excluded_path(Path::new(
625 ".kranz/tickets/some-ticket.md"
626 )));
627 }
628
629 #[test]
635 fn domain_lint_committed_config_is_hash_form_only() {
636 let committed = concat!(
637 env!("CARGO_MANIFEST_DIR"),
638 "/../../.kranz/domain-denylist.json"
639 );
640 let text = std::fs::read_to_string(committed)
641 .unwrap_or_else(|err| panic!("read {committed}: {err}"));
642 let value: serde_json::Value =
643 serde_json::from_str(&text).expect("committed config parses");
644 let object = value.as_object().expect("object");
645 for key in object.keys() {
646 assert!(
647 matches!(key.as_str(), "version" | "salt" | "hash" | "terms"),
648 "unexpected key {key:?} — nowhere for readable terms to hide"
649 );
650 }
651 let denylist = load_denylist(&text).expect("committed config validates");
652 assert!(denylist.hashes.len() >= 3, "committed denylist is seeded");
653 let synthetic = salted_hash(&denylist.salt, "zz acme lint canary");
654 assert!(
655 !denylist.hashes.contains(&synthetic),
656 "synthetic test vocabulary must never seed the real config"
657 );
658 }
659}