1pub(crate) const SPEC_PATHS: [&str; 2] = [".forgejo/attest-inputs", ".github/attest-inputs"];
12
13const MAX_SPEC_BYTES: usize = 65536;
21const MAX_SPEC_GATES: usize = 64;
22const MAX_SPEC_PATHS: usize = 64;
23
24fn valid_gate(name: &str) -> bool {
26 let mut chars = name.chars();
27 match chars.next() {
28 Some(c) if c.is_ascii_alphanumeric() => {}
29 _ => return false,
30 }
31 name.len() <= 64 && chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
32}
33
34#[derive(Debug, Clone, PartialEq)]
39pub(crate) struct PathTok {
40 pub(crate) path: String,
41 pub(crate) optional: bool,
42}
43
44fn bad_path(tok: &str) -> bool {
49 let tok = match tok.strip_prefix('?') {
50 Some("") => return true,
51 Some(rest) => rest,
52 None => tok,
53 };
54 tok.starts_with(':')
55 || tok.starts_with('/')
56 || tok.starts_with("./")
57 || tok.starts_with("../")
58 || tok.ends_with('/')
59 || tok
60 .chars()
61 .any(|c| matches!(c, '*' | '?' | '[' | ']' | '\\'))
62 || tok
63 .split('/')
64 .any(|c| c.is_empty() || c == "." || c == "..")
65}
66
67pub(crate) fn parse_spec(bytes: &[u8]) -> Option<Vec<(String, Vec<PathTok>)>> {
69 if bytes.len() > MAX_SPEC_BYTES
70 || bytes
71 .iter()
72 .any(|&b| !(b == b' ' || b == b'\t' || b == b'\n' || (0x21..=0x7e).contains(&b)))
73 {
74 return None;
75 }
76 let text = std::str::from_utf8(bytes).ok()?;
77 let mut gates: Vec<(String, Vec<PathTok>)> = Vec::new();
78 for line in text.split('\n') {
79 let mut toks = line.split([' ', '\t']).filter(|t| !t.is_empty());
80 let Some(gate) = toks.next() else { continue };
81 if gate.starts_with('#') {
82 continue;
83 }
84 if !valid_gate(gate) || gates.iter().any(|(g, _)| g == gate) {
85 return None;
86 }
87 let paths: Vec<String> = toks.map(String::from).collect();
88 if paths.is_empty() || paths.len() > MAX_SPEC_PATHS || paths.iter().any(|p| bad_path(p)) {
89 return None;
90 }
91 let paths = paths
92 .into_iter()
93 .map(|p| match p.strip_prefix('?') {
94 Some(rest) => PathTok {
95 path: rest.to_string(),
96 optional: true,
97 },
98 None => PathTok {
99 path: p,
100 optional: false,
101 },
102 })
103 .collect();
104 gates.push((gate.to_string(), paths));
105 if gates.len() > MAX_SPEC_GATES {
106 return None;
107 }
108 }
109 Some(gates)
110}
111
112pub(crate) fn spec_at(tree: &str) -> Option<Vec<(String, Vec<PathTok>)>> {
116 read_spec(tree).ok().map(|(_, spec)| spec)
117}
118
119#[derive(Debug, PartialEq)]
123pub enum SpecError {
124 Missing,
125 Ambiguous,
126 Invalid(&'static str),
127 Unlisted,
129}
130
131pub(crate) fn read_spec(tree: &str) -> Result<(&'static str, Spec), SpecError> {
133 let listing = crate::git::stdout_raw(&[
138 "ls-tree",
139 "--full-tree",
140 tree,
141 "--",
142 SPEC_PATHS[0],
143 SPEC_PATHS[1],
144 ])
145 .ok_or(SpecError::Unlisted)?;
146 let listing = String::from_utf8_lossy(&listing);
147 let present: Vec<&'static str> = SPEC_PATHS
148 .iter()
149 .copied()
150 .filter(|p| {
151 listing
152 .lines()
153 .any(|l| l.split_once('\t').is_some_and(|(_, name)| name == *p))
154 })
155 .collect();
156 let path = match present.as_slice() {
157 [] => return Err(SpecError::Missing),
158 [path] => *path,
159 _ => return Err(SpecError::Ambiguous),
160 };
161 let bytes = crate::git::stdout_raw(&["cat-file", "blob", &format!("{tree}:{path}")])
162 .ok_or(SpecError::Invalid(path))?;
163 parse_spec(&bytes)
164 .map(|spec| (path, spec))
165 .ok_or(SpecError::Invalid(path))
166}
167
168pub(crate) type Spec = Vec<(String, Vec<PathTok>)>;
169
170pub const EXEMPT: [&str; 1] = ["docs/plans/"];
174
175#[derive(Debug, Default, PartialEq)]
178pub struct Findings {
179 pub undeclared: Vec<String>,
181 pub unfingerprintable: Vec<String>,
184 pub uncovered: Vec<(String, Vec<String>)>,
186}
187
188const LIST_CAP: usize = 50;
190
191impl SpecError {
192 pub fn render(&self, tree: &str) -> String {
194 match self {
195 SpecError::Missing => format!(
196 "check-attest-inputs: no {} or {} on {tree}",
197 SPEC_PATHS[0], SPEC_PATHS[1]
198 ),
199 SpecError::Ambiguous => format!(
200 "check-attest-inputs: both {} and {} exist on {tree}; that disables the route",
201 SPEC_PATHS[0], SPEC_PATHS[1]
202 ),
203 SpecError::Invalid(path) => format!(
204 "check-attest-inputs: {path} is invalid on {tree} (one violation invalidates the whole spec; see attest SPEC, \"Input fingerprints\")"
205 ),
206 SpecError::Unlisted => {
207 format!("check-attest-inputs: git could not list {tree}, or it is empty")
208 }
209 }
210 }
211}
212
213impl Findings {
214 pub fn render(&self) -> String {
217 let mut out = String::new();
218 for g in &self.undeclared {
219 out.push_str(&format!(
220 "check-attest-inputs: {g}: not declared in the spec\n"
221 ));
222 }
223 for g in &self.unfingerprintable {
224 out.push_str(&format!(
225 "check-attest-inputs: {g}: cannot be fingerprinted (a required path is missing)\n"
226 ));
227 }
228 for (g, paths) in &self.uncovered {
229 out.push_str(&format!("check-attest-inputs: {g}: not covered:\n"));
230 for p in paths.iter().take(LIST_CAP) {
231 out.push_str(&format!(" {p}\n"));
232 }
233 if paths.len() > LIST_CAP {
234 out.push_str(&format!(" ... and {} more\n", paths.len() - LIST_CAP));
235 }
236 }
237 out.push_str(
238 "check-attest-inputs: fix: declare each path above for that gate (a directory \
239 covers everything under it); only a path no gate reads may be exempt, and that \
240 is a code-review decision. An undeclared path makes CI and the hooks skip \
241 wrongly the day it changes.\n",
242 );
243 out
244 }
245
246 pub fn is_empty(&self) -> bool {
247 self.undeclared.is_empty() && self.unfingerprintable.is_empty() && self.uncovered.is_empty()
248 }
249}
250
251fn is_implicit(file: &[u8]) -> bool {
255 SPEC_PATHS.iter().any(|p| p.as_bytes() == file)
256 || file == b".gitmodules"
257 || file == b".gitattributes"
258}
259
260fn covers(decl: &str, file: &[u8]) -> bool {
265 let d = decl.as_bytes();
266 file == d || (file.len() > d.len() && file.starts_with(d) && file[d.len()] == b'/')
267}
268
269pub(crate) fn declares(toks: &[PathTok], file: &str) -> bool {
272 toks.iter().any(|t| covers(&t.path, file.as_bytes()))
273}
274
275pub(crate) fn coverage(
278 spec: &Spec,
279 required: &[String],
280 files: &[Vec<u8>],
281 can_fingerprint: impl Fn(&[PathTok]) -> bool,
282) -> Findings {
283 let mut out = Findings::default();
284 for gate in required {
285 let Some((_, toks)) = spec.iter().find(|(g, _)| g == gate) else {
286 out.undeclared.push(gate.clone());
287 continue;
288 };
289 if !can_fingerprint(toks) {
290 out.unfingerprintable.push(gate.clone());
291 }
292 let missing: Vec<String> = files
293 .iter()
294 .filter(|f| !EXEMPT.iter().any(|e| f.starts_with(e.as_bytes())))
295 .filter(|f| !is_implicit(f))
296 .filter(|f| !toks.iter().any(|t| covers(&t.path, f)))
297 .map(|f| String::from_utf8_lossy(f).into_owned())
298 .collect();
299 if !missing.is_empty() {
300 out.uncovered.push((gate.clone(), missing));
301 }
302 }
303 out
304}
305
306pub fn check(tree: &str, required: &[String]) -> Result<Findings, SpecError> {
315 let (_, spec) = read_spec(tree)?;
316 let listing =
317 crate::git::stdout_raw(&["ls-tree", "-r", "-z", "--full-tree", "--name-only", tree])
318 .filter(|l| !l.is_empty())
319 .ok_or(SpecError::Unlisted)?;
320 let files: Vec<Vec<u8>> = listing
321 .split(|&b| b == 0)
322 .filter(|f| !f.is_empty())
323 .map(<[u8]>::to_vec)
324 .collect();
325 Ok(coverage(&spec, required, &files, |toks| {
326 fingerprint(tree, toks).is_some()
327 }))
328}
329
330pub(crate) fn implicit_inputs(tokens: &[String]) -> Vec<String> {
334 let mut attrs: Vec<String> = vec![".gitattributes".to_string()];
335 for t in tokens {
336 let comps: Vec<&str> = t.split('/').collect();
337 let mut prefix = String::new();
338 for c in &comps[..comps.len().saturating_sub(1)] {
339 if !prefix.is_empty() {
340 prefix.push('/');
341 }
342 prefix.push_str(c);
343 attrs.push(format!("{prefix}/.gitattributes"));
344 }
345 }
346 attrs.sort();
347 attrs.dedup();
348 let mut out: Vec<String> = SPEC_PATHS.iter().map(|s| s.to_string()).collect();
349 out.push(".gitmodules".to_string());
350 out.extend(attrs);
351 out
352}
353
354pub(crate) fn is_oid(s: &str) -> bool {
355 (s.len() == 40 || s.len() == 64)
356 && s.bytes()
357 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
358}
359
360pub(crate) fn fingerprint(tree: &str, toks: &[PathTok]) -> Option<String> {
366 let required: Vec<&str> = toks
367 .iter()
368 .filter(|t| !t.optional)
369 .map(|t| t.path.as_str())
370 .collect();
371 if !required.is_empty() {
372 let names: String = required.iter().map(|p| format!("{tree}:{p}\n")).collect();
373 let answers = crate::git::stdout_piped(&["cat-file", "--batch-check"], &names)?;
374 if answers.lines().count() != required.len()
375 || answers.lines().any(|l| l.ends_with(" missing"))
376 {
377 return None;
378 }
379 }
380 let paths: Vec<String> = toks.iter().map(|t| t.path.clone()).collect();
381 let mut args: Vec<String> = vec![
382 "ls-tree".into(),
383 "-r".into(),
384 "-z".into(),
385 "--full-tree".into(),
386 tree.to_string(),
387 "--".into(),
388 ];
389 args.extend(implicit_inputs(&paths));
390 args.extend(paths.iter().cloned());
391 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
392 let listing = crate::git::stdout_raw(&argv)?;
393 if listing.is_empty() {
394 return None;
395 }
396 crate::git::stdout_piped_in(
397 std::path::Path::new("."),
398 &["hash-object", "--stdin"],
399 &listing,
400 )
401 .filter(|s| is_oid(s))
402}
403
404pub(crate) fn input_key(gate: &str, fp: &str) -> Option<String> {
406 let pre = format!("amont-attest-input {gate} {fp}\n");
407 crate::git::stdout_piped_in(
408 std::path::Path::new("."),
409 &["hash-object", "--stdin"],
410 pre.as_bytes(),
411 )
412 .filter(|s| is_oid(s))
413}
414
415pub(crate) fn conf_says_plan_exempt_off(bytes: &[u8]) -> bool {
428 bytes.split(|&b| b == b'\n').any(|line| {
429 let line: Vec<u8> = line.iter().copied().filter(|&b| b != b'\r').collect();
430 let words: Vec<&[u8]> = line
431 .split(|&b| b == b' ' || b == b'\t')
432 .filter(|w| !w.is_empty())
433 .collect();
434 words.len() == 3
435 && words[0].eq_ignore_ascii_case(b"set")
436 && words[1].eq_ignore_ascii_case(b"planExempt")
437 && words[2].eq_ignore_ascii_case(b"off")
438 })
439}
440
441pub(crate) fn plan_exempt_off(tree: &str) -> bool {
447 let manifest = crate::manifest::MANIFEST;
448 let Some(listing) = crate::git::stdout_raw(&["ls-tree", "--full-tree", tree, "--", manifest])
449 else {
450 return true;
451 };
452 if listing.is_empty() {
453 return false;
454 }
455 match crate::git::stdout_raw(&["cat-file", "blob", &format!("{tree}:{manifest}")]) {
456 Some(bytes) => conf_says_plan_exempt_off(&bytes),
457 None => true,
458 }
459}
460
461pub(crate) fn canon_text(listing: &str) -> Option<String> {
467 let mut out = String::with_capacity(listing.len());
468 for line in listing.lines() {
469 let (head, path) = line.split_once('\t')?;
470 let mut f = head.split(' ');
471 let (mode, kind, oid) = (f.next()?, f.next()?, f.next()?);
472 if f.next().is_some() {
473 return None;
474 }
475 let under_plans = path.starts_with("docs/plans/") || path.starts_with("\"docs/plans/");
477 if matches!(mode, "100644" | "100755") && under_plans {
478 out.push_str(&format!(
479 "{mode} {kind} {}\t{path}\n",
480 "0".repeat(oid.len())
481 ));
482 } else {
483 out.push_str(line);
484 out.push('\n');
485 }
486 }
487 (!out.is_empty()).then_some(out)
488}
489
490pub(crate) fn canon(tree: &str) -> Option<String> {
492 let listing = crate::git::stdout_raw(&[
493 "-c",
494 "core.quotePath=true",
495 "ls-tree",
496 "-r",
497 "--full-tree",
498 tree,
499 ])?;
500 let text = canon_text(std::str::from_utf8(&listing).ok()?)?;
501 crate::git::stdout_piped_in(
502 std::path::Path::new("."),
503 &["hash-object", "--stdin"],
504 text.as_bytes(),
505 )
506 .filter(|s| is_oid(s))
507}
508
509fn hashed(preimage: &str) -> Option<String> {
510 crate::git::stdout_piped_in(
511 std::path::Path::new("."),
512 &["hash-object", "--stdin"],
513 preimage.as_bytes(),
514 )
515 .filter(|s| is_oid(s))
516}
517
518pub(crate) fn canon_key(canon: &str) -> Option<String> {
520 hashed(&format!("amont-attest-canon {canon}\n"))
521}
522
523pub(crate) fn canon_gate_key(canon: &str) -> Option<String> {
527 hashed(&format!("amont-gate-canon {canon}\n"))
528}
529
530pub(crate) fn canonical_route(tree: &str) -> Option<String> {
536 match read_spec(tree) {
537 Err(SpecError::Missing) => {}
538 _ => return None,
539 }
540 canon_unless_off(tree)
541}
542
543pub(crate) fn canon_for_attest(tree: &str) -> Option<String> {
547 match read_spec(tree) {
548 Ok(_) | Err(SpecError::Missing) => {}
549 Err(_) => return None,
550 }
551 canon_unless_off(tree)
552}
553
554fn canon_unless_off(tree: &str) -> Option<String> {
555 if plan_exempt_off(tree) {
556 return None;
557 }
558 canon(tree)
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 const O1: &str = "1111111111111111111111111111111111111111";
566 const O2: &str = "2222222222222222222222222222222222222222";
567 const Z: &str = "0000000000000000000000000000000000000000";
568
569 #[test]
570 fn the_opt_out_is_one_tolerant_reading() {
571 for yes in [
572 "set planExempt off\n",
573 "SET PLANEXEMPT OFF",
574 " set\tplanExempt off \r\n",
575 "gate x\nset planExempt off\ngate y\n",
576 "set planExempt on\nset planExempt off\n",
577 "set planExempt off\nset planExempt on\n",
578 ] {
579 assert!(conf_says_plan_exempt_off(yes.as_bytes()), "{yes:?}");
580 }
581 for no in [
582 "",
583 "set planExempt on\n",
584 "set planExempt off # trailing comment\n",
585 "# set planExempt off\n",
586 "set planExempt\n",
587 "set planExempt off off\n",
588 "setplanExempt off\n",
589 "set planexempt of\n",
590 ] {
591 assert!(!conf_says_plan_exempt_off(no.as_bytes()), "{no:?}");
592 }
593 }
594
595 #[test]
596 fn canon_text_neutralises_plan_content_and_keeps_everything_else() {
597 let a = format!(
598 "100644 blob {O1}\tREADME.md\n100644 blob {O1}\tdocs/plans/a.md\n100755 blob {O1}\tdocs/plans/sub/run.sh\n"
599 );
600 let edited = a.replace(
601 &format!("{O1}\tdocs/plans/a.md"),
602 &format!("{O2}\tdocs/plans/a.md"),
603 );
604 assert_eq!(canon_text(&a), canon_text(&edited));
605 assert!(canon_text(&a)
606 .unwrap()
607 .contains(&format!("100644 blob {Z}\tdocs/plans/a.md\n")));
608 let other = a.replace(&format!("{O1}\tREADME.md"), &format!("{O2}\tREADME.md"));
609 assert_ne!(canon_text(&a), canon_text(&other));
610 let added = format!("{a}100644 blob {O1}\tdocs/plans/b.md\n");
611 assert_ne!(canon_text(&a), canon_text(&added));
612 let renamed = a.replace("docs/plans/a.md", "docs/plans/c.md");
613 assert_ne!(canon_text(&a), canon_text(&renamed));
614 }
615
616 type CanonCase = (String, String, Option<String>);
619
620 fn canon_vectors(text: &str) -> Vec<CanonCase> {
625 let mut cases = Vec::new();
626 let mut open: Option<(String, String, Option<String>, bool)> = None;
627 for (n, line) in text.split('\n').enumerate() {
628 let at = n + 1;
629 match open.as_mut() {
630 None if line.is_empty() || line.starts_with('#') => {}
631 None => {
632 let name = line
633 .strip_prefix("case ")
634 .unwrap_or_else(|| panic!("canon.txt:{at}: expected `case`: {line:?}"));
635 open = Some((name.to_string(), String::new(), None, false));
636 }
637 Some((_, listing, out, none)) => {
638 if line == "end" {
639 let (name, listing, out, none) = open.take().unwrap();
640 assert!(
641 out.is_some() != none,
642 "canon.txt:{at}: `{name}` needs `out` lines or one `none`"
643 );
644 cases.push((name, listing, out));
645 } else if line == "none" {
646 assert!(out.is_none() && !*none, "canon.txt:{at}: misplaced `none`");
647 *none = true;
648 } else if let Some(rec) = line.strip_prefix("in ") {
649 assert!(
650 out.is_none() && !*none,
651 "canon.txt:{at}: `in` after the answer"
652 );
653 listing.push_str(rec);
654 listing.push('\n');
655 } else if let Some(rec) = line.strip_prefix("out ") {
656 assert!(!*none, "canon.txt:{at}: `out` after `none`");
657 let o = out.get_or_insert_with(String::new);
658 o.push_str(rec);
659 o.push('\n');
660 } else {
661 panic!("canon.txt:{at}: not a directive: {line:?}");
662 }
663 }
664 }
665 }
666 assert!(open.is_none(), "canon.txt: the last case has no `end`");
667 cases
668 }
669
670 #[test]
671 fn canon_text_gives_every_known_answer() {
672 let cases = canon_vectors(include_str!("../tests/vectors/canon.txt"));
673 assert!(cases.iter().any(|c| c.2.is_some()) && cases.iter().any(|c| c.2.is_none()));
674 for (name, listing, want) in &cases {
675 assert_eq!(canon_text(listing).as_ref(), want.as_ref(), "{name}");
676 }
677 }
678
679 #[test]
680 fn the_vectors_reader_refuses_a_damaged_file() {
681 for bad in [
682 "stray\n",
683 "case x\nin a\tb\n",
684 "case x\nend\n",
685 "case x\nnone\nout y\nend\n",
686 "case x\nout y\nin a\tb\nend\n",
687 "case x\nwhat\nend\n",
688 ] {
689 assert!(
690 std::panic::catch_unwind(|| canon_vectors(bad)).is_err(),
691 "{bad:?}"
692 );
693 }
694 }
695
696 #[test]
697 fn a_tree_git_cannot_list_has_no_spec_and_opens_no_route() {
698 let bogus = "0123456789012345678901234567890123456789";
699 assert_eq!(read_spec(bogus).err(), Some(SpecError::Unlisted));
700 assert_eq!(canonical_route(bogus), None);
701 assert_eq!(canon_for_attest(bogus), None);
702 assert!(plan_exempt_off(bogus));
704 }
705}