1use std::path::Path;
5
6use crate::git;
7
8pub const SPEC_PATHS: [&str; 2] = [".forgejo/attest-inputs", ".github/attest-inputs"];
11pub const MAX_SPEC_BYTES: usize = 65536;
12pub const MAX_SPEC_GATES: usize = 64;
13pub const MAX_SPEC_PATHS: usize = 64;
14
15#[derive(Debug, PartialEq, Clone)]
24pub struct PathTok {
25 pub path: String,
26 pub optional: bool,
27}
28
29#[derive(Debug, PartialEq, Default)]
31#[non_exhaustive]
32pub struct Spec {
33 pub gates: Vec<(String, Vec<PathTok>)>,
34 pub plan_exempt_off: bool,
36}
37
38pub const PLAN_EXEMPT_DIRECTIVE: [&str; 2] = ["@plan-exempt", "off"];
40
41pub fn is_plan_exempt_directive(line: &[u8]) -> bool {
44 let words: Vec<&[u8]> = line
45 .split(|&b| b == b' ' || b == b'\t')
46 .filter(|w| !w.is_empty())
47 .collect();
48 words.len() == 2
49 && words[0] == PLAN_EXEMPT_DIRECTIVE[0].as_bytes()
50 && words[1] == PLAN_EXEMPT_DIRECTIVE[1].as_bytes()
51}
52
53impl Spec {
54 pub fn paths_of(&self, gate: &str) -> Option<&[PathTok]> {
55 self.gates
56 .iter()
57 .find(|(g, _)| g == gate)
58 .map(|(_, p)| p.as_slice())
59 }
60}
61
62pub(crate) fn valid_gate(name: &str) -> bool {
64 let mut chars = name.chars();
65 match chars.next() {
66 Some(c) if c.is_ascii_alphanumeric() => {}
67 _ => return false,
68 }
69 name.len() <= 64 && chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum PathProblem {
79 NamesNothing,
81 Magic,
83 Absolute,
85 Relative,
87 TrailingSlash,
89 Glob(char),
91 BadComponent,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum Error {
100 TooLarge,
102 Byte(u8),
104 GateName { line: usize, gate: String },
106 Duplicate { line: usize, gate: String },
108 NoPaths { line: usize, gate: String },
110 TooManyPaths { line: usize, gate: String },
112 Path {
114 line: usize,
115 path: String,
116 problem: PathProblem,
117 },
118 TooManyGates,
120 Directive { line: usize, text: String },
122}
123
124impl std::fmt::Display for PathProblem {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 match self {
127 PathProblem::NamesNothing => write!(f, "names nothing"),
128 PathProblem::Magic => write!(f, "starts with `:` (pathspec magic is not allowed)"),
129 PathProblem::Absolute => {
130 write!(f, "is absolute; paths are relative to the repository root")
131 }
132 PathProblem::Relative => write!(f, "starts with `./` or `../`"),
133 PathProblem::TrailingSlash => write!(f, "ends with `/`; name the directory without it"),
134 PathProblem::Glob(c) => write!(
135 f,
136 "contains `{c}`; git ls-tree does not glob, only literal paths are accepted"
137 ),
138 PathProblem::BadComponent => write!(f, "has an empty, `.` or `..` component"),
139 }
140 }
141}
142
143impl std::fmt::Display for Error {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 match self {
146 Error::TooLarge => write!(f, "is larger than {MAX_SPEC_BYTES} bytes"),
147 Error::Byte(b) => write!(
148 f,
149 "contains byte 0x{b:02x}; only printable ASCII, space, tab and LF are allowed"
150 ),
151 Error::GateName { line, gate } => write!(
152 f,
153 "line {line}: gate name `{gate}` is not [A-Za-z0-9][A-Za-z0-9._-]* of at most 64 characters"
154 ),
155 Error::Duplicate { line, gate } => {
156 write!(f, "line {line}: gate `{gate}` is declared twice")
157 }
158 Error::NoPaths { line, gate } => {
159 write!(f, "line {line}: gate `{gate}` declares no paths")
160 }
161 Error::TooManyPaths { line, gate } => write!(
162 f,
163 "line {line}: gate `{gate}` declares more than {MAX_SPEC_PATHS} paths"
164 ),
165 Error::Path {
166 line,
167 path,
168 problem,
169 } => write!(f, "line {line}: path `{path}` {problem}"),
170 Error::TooManyGates => write!(f, "declares more than {MAX_SPEC_GATES} gates"),
171 Error::Directive { line, text } => write!(
172 f,
173 "line {line}: `{text}` is not a directive; the only one is `@plan-exempt off`"
174 ),
175 }
176 }
177}
178
179impl std::error::Error for Error {}
180
181pub(crate) fn path_error(tok: &str) -> Option<(String, PathProblem)> {
184 let tok = match tok.strip_prefix('?') {
187 Some("") => return Some(("?".into(), PathProblem::NamesNothing)),
188 Some(rest) => rest,
189 None => tok,
190 };
191 let bad = |p: PathProblem| Some((tok.to_string(), p));
192 if tok.starts_with(':') {
193 return bad(PathProblem::Magic);
194 }
195 if tok.starts_with('/') {
196 return bad(PathProblem::Absolute);
197 }
198 if tok.starts_with("./") || tok.starts_with("../") {
199 return bad(PathProblem::Relative);
200 }
201 if tok.ends_with('/') {
202 return bad(PathProblem::TrailingSlash);
203 }
204 if let Some(c) = tok
205 .chars()
206 .find(|c| matches!(c, '*' | '?' | '[' | ']' | '\\'))
207 {
208 return bad(PathProblem::Glob(c));
209 }
210 if tok
211 .split('/')
212 .any(|c| c.is_empty() || c == "." || c == "..")
213 {
214 return bad(PathProblem::BadComponent);
215 }
216 None
217}
218
219pub fn parse_spec(bytes: &[u8]) -> Result<Spec, Error> {
223 if bytes.len() > MAX_SPEC_BYTES {
224 return Err(Error::TooLarge);
225 }
226 if let Some(&b) = bytes
227 .iter()
228 .find(|&&b| !(b == b' ' || b == b'\t' || b == b'\n' || (0x21..=0x7e).contains(&b)))
229 {
230 return Err(Error::Byte(b));
231 }
232 let text = std::str::from_utf8(bytes).expect("ASCII is UTF-8");
234 let mut gates: Vec<(String, Vec<PathTok>)> = Vec::new();
235 let mut plan_exempt_off = false;
236 for (n, line) in text.split('\n').enumerate() {
237 let line_no = n + 1;
238 let mut toks = line.split([' ', '\t']).filter(|t| !t.is_empty());
239 let Some(gate) = toks.next() else { continue };
240 if gate.starts_with('#') {
241 continue;
242 }
243 if gate.starts_with('@') {
246 if is_plan_exempt_directive(line.as_bytes()) {
247 plan_exempt_off = true;
248 continue;
249 }
250 return Err(Error::Directive {
251 line: line_no,
252 text: line.trim().to_string(),
253 });
254 }
255 let at = |gate: &str| (line_no, gate.to_string());
256 if !valid_gate(gate) {
257 let (line, gate) = at(gate);
258 return Err(Error::GateName { line, gate });
259 }
260 if gates.iter().any(|(g, _)| g == gate) {
261 let (line, gate) = at(gate);
262 return Err(Error::Duplicate { line, gate });
263 }
264 let paths: Vec<String> = toks.map(String::from).collect();
265 if paths.is_empty() {
266 let (line, gate) = at(gate);
267 return Err(Error::NoPaths { line, gate });
268 }
269 if paths.len() > MAX_SPEC_PATHS {
270 let (line, gate) = at(gate);
271 return Err(Error::TooManyPaths { line, gate });
272 }
273 for p in &paths {
274 if let Some((path, problem)) = path_error(p) {
275 return Err(Error::Path {
276 line: line_no,
277 path,
278 problem,
279 });
280 }
281 }
282 let paths = paths
283 .into_iter()
284 .map(|p| match p.strip_prefix('?') {
285 Some(rest) => PathTok {
286 path: rest.to_string(),
287 optional: true,
288 },
289 None => PathTok {
290 path: p,
291 optional: false,
292 },
293 })
294 .collect();
295 gates.push((gate.to_string(), paths));
296 if gates.len() > MAX_SPEC_GATES {
297 return Err(Error::TooManyGates);
298 }
299 }
300 Ok(Spec {
301 gates,
302 plan_exempt_off,
303 })
304}
305
306pub fn implicit_inputs(tokens: &[String]) -> Vec<String> {
312 let mut attrs: Vec<String> = vec![".gitattributes".to_string()];
313 for t in tokens {
314 let comps: Vec<&str> = t.split('/').collect();
315 let mut prefix = String::new();
316 for c in &comps[..comps.len().saturating_sub(1)] {
317 if !prefix.is_empty() {
318 prefix.push('/');
319 }
320 prefix.push_str(c);
321 attrs.push(format!("{prefix}/.gitattributes"));
322 }
323 }
324 attrs.sort();
325 attrs.dedup();
326 let mut out: Vec<String> = SPEC_PATHS.iter().map(|s| s.to_string()).collect();
327 out.push(".gitmodules".to_string());
328 out.extend(attrs);
329 out
330}
331
332pub fn conf_says_plan_exempt_off(bytes: &[u8]) -> bool {
338 bytes.split(|&b| b == b'\n').any(|line| {
339 let line: Vec<u8> = line.iter().copied().filter(|&b| b != b'\r').collect();
340 let words: Vec<&[u8]> = line
341 .split(|&b| b == b' ' || b == b'\t')
342 .filter(|w| !w.is_empty())
343 .collect();
344 words.len() == 3
345 && words[0].eq_ignore_ascii_case(b"set")
346 && words[1].eq_ignore_ascii_case(b"planExempt")
347 && words[2].eq_ignore_ascii_case(b"off")
348 })
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353#[non_exhaustive]
354pub enum PlanExempt {
355 On,
357 Off,
359 OffByAmontConf,
362 Unreadable,
365}
366
367impl PlanExempt {
368 pub fn is_off(self) -> bool {
370 !matches!(self, PlanExempt::On)
371 }
372}
373
374pub const DEPRECATED_OPT_OUT: &str =
376 "deprecated: planExempt in amont.conf; also write @plan-exempt off in attest-inputs";
377
378pub fn plan_exempt(root: &Path, tree: &str) -> PlanExempt {
383 for path in SPEC_PATHS {
384 let Some(listing) =
385 git::stdout_bytes_in(root, &["ls-tree", "--full-tree", tree, "--", path])
386 else {
387 return PlanExempt::Unreadable;
388 };
389 if listing.is_empty() {
390 continue;
391 }
392 match git::stdout_bytes_in(root, &["cat-file", "blob", &format!("{tree}:{path}")]) {
393 Some(bytes) if bytes.split(|&b| b == b'\n').any(is_plan_exempt_directive) => {
394 return PlanExempt::Off
395 }
396 Some(_) => {}
397 None => return PlanExempt::Unreadable,
398 }
399 }
400 let Some(listing) =
401 git::stdout_bytes_in(root, &["ls-tree", "--full-tree", tree, "--", "amont.conf"])
402 else {
403 return PlanExempt::Unreadable;
404 };
405 if listing.is_empty() {
406 return PlanExempt::On;
407 }
408 match git::stdout_bytes_in(root, &["cat-file", "blob", &format!("{tree}:amont.conf")]) {
409 Some(bytes) if conf_says_plan_exempt_off(&bytes) => PlanExempt::OffByAmontConf,
410 Some(_) => PlanExempt::On,
411 None => PlanExempt::Unreadable,
412 }
413}
414
415#[derive(Debug, Clone, PartialEq, Eq)]
418#[non_exhaustive]
419pub enum AtError {
420 Ambiguous,
422 Unreadable(&'static str),
424 Invalid(&'static str, Error),
426 Unlisted,
428}
429
430pub fn at(root: &Path, tree: &str) -> Result<Option<(&'static str, Spec)>, AtError> {
438 let listing = git::stdout_bytes_in(
439 root,
440 &[
441 "ls-tree",
442 "--full-tree",
443 tree,
444 "--",
445 SPEC_PATHS[0],
446 SPEC_PATHS[1],
447 ],
448 )
449 .ok_or(AtError::Unlisted)?;
450 let present: Vec<&'static str> = SPEC_PATHS
451 .iter()
452 .copied()
453 .filter(|p| {
454 listing
455 .split(|&b| b == b'\n')
456 .filter_map(|l| l.iter().position(|&b| b == b'\t').map(|i| &l[i + 1..]))
457 .any(|name| name == p.as_bytes())
458 })
459 .collect();
460 let path = match present.as_slice() {
461 [] => return Ok(None),
462 [one] => *one,
463 _ => return Err(AtError::Ambiguous),
464 };
465 let bytes = git::stdout_bytes_in(root, &["cat-file", "blob", &format!("{tree}:{path}")])
466 .ok_or(AtError::Unreadable(path))?;
467 parse_spec(&bytes)
468 .map(|spec| Some((path, spec)))
469 .map_err(|e| AtError::Invalid(path, e))
470}
471
472pub const EXEMPT: [&str; 1] = ["docs/plans/"];
480
481const LIST_CAP: usize = 50;
483
484#[derive(Debug, Clone, PartialEq, Eq)]
487#[non_exhaustive]
488pub enum CoverageError {
489 Missing,
491 Ambiguous,
493 Invalid(&'static str),
495 Unlisted,
497}
498
499impl CoverageError {
500 pub fn render(&self, tree: &str) -> String {
502 match self {
503 CoverageError::Missing => format!(
504 "check-attest-inputs: no {} or {} on {tree}",
505 SPEC_PATHS[0], SPEC_PATHS[1]
506 ),
507 CoverageError::Ambiguous => format!(
508 "check-attest-inputs: both {} and {} exist on {tree}; that disables the route",
509 SPEC_PATHS[0], SPEC_PATHS[1]
510 ),
511 CoverageError::Invalid(path) => format!(
512 "check-attest-inputs: {path} is invalid on {tree} (one violation invalidates the whole spec; see attest SPEC, \"Input fingerprints\")"
513 ),
514 CoverageError::Unlisted => {
515 format!("check-attest-inputs: git could not list {tree}, or it is empty")
516 }
517 }
518 }
519}
520
521#[derive(Debug, Default, Clone, PartialEq, Eq)]
524pub struct Findings {
525 pub undeclared: Vec<String>,
527 pub unfingerprintable: Vec<String>,
529 pub uncovered: Vec<(String, Vec<String>)>,
531}
532
533impl Findings {
534 pub fn render(&self) -> String {
537 let mut out = String::new();
538 for g in &self.undeclared {
539 out.push_str(&format!(
540 "check-attest-inputs: {g}: not declared in the spec\n"
541 ));
542 }
543 for g in &self.unfingerprintable {
544 out.push_str(&format!(
545 "check-attest-inputs: {g}: cannot be fingerprinted (a required path is missing)\n"
546 ));
547 }
548 for (g, paths) in &self.uncovered {
549 out.push_str(&format!("check-attest-inputs: {g}: not covered:\n"));
550 for p in paths.iter().take(LIST_CAP) {
551 out.push_str(&format!(" {p}\n"));
552 }
553 if paths.len() > LIST_CAP {
554 out.push_str(&format!(" ... and {} more\n", paths.len() - LIST_CAP));
555 }
556 }
557 out.push_str(
558 "check-attest-inputs: fix: declare each path above for that gate (a directory \
559 covers everything under it); only a path no gate reads may be exempt, and that \
560 is a code-review decision. An undeclared path makes CI and the hooks skip \
561 wrongly the day it changes.\n",
562 );
563 out
564 }
565
566 pub fn is_empty(&self) -> bool {
567 self.undeclared.is_empty() && self.unfingerprintable.is_empty() && self.uncovered.is_empty()
568 }
569}
570
571fn is_implicit(file: &[u8]) -> bool {
574 SPEC_PATHS.iter().any(|p| p.as_bytes() == file)
575 || file == b".gitmodules"
576 || file == b".gitattributes"
577}
578
579fn covers(decl: &str, file: &[u8]) -> bool {
583 let d = decl.as_bytes();
584 file == d || (file.len() > d.len() && file.starts_with(d) && file[d.len()] == b'/')
585}
586
587pub fn coverage(
591 spec: &Spec,
592 required: &[String],
593 files: &[Vec<u8>],
594 can_fingerprint: impl Fn(&str) -> bool,
595) -> Findings {
596 let mut out = Findings::default();
597 for gate in required {
598 let Some(toks) = spec.paths_of(gate) else {
599 out.undeclared.push(gate.clone());
600 continue;
601 };
602 if !can_fingerprint(gate) {
603 out.unfingerprintable.push(gate.clone());
604 }
605 let missing: Vec<String> = files
606 .iter()
607 .filter(|f| !EXEMPT.iter().any(|e| f.starts_with(e.as_bytes())))
608 .filter(|f| !is_implicit(f))
609 .filter(|f| !toks.iter().any(|t| covers(&t.path, f)))
610 .map(|f| String::from_utf8_lossy(f).into_owned())
611 .collect();
612 if !missing.is_empty() {
613 out.uncovered.push((gate.clone(), missing));
614 }
615 }
616 out
617}
618
619pub fn check_coverage(
627 root: &Path,
628 tree: &str,
629 required: &[String],
630) -> Result<Findings, CoverageError> {
631 let spec = match at(root, tree) {
632 Ok(Some((_, spec))) => spec,
633 Ok(None) => return Err(CoverageError::Missing),
634 Err(AtError::Ambiguous) => return Err(CoverageError::Ambiguous),
635 Err(AtError::Unreadable(p)) | Err(AtError::Invalid(p, _)) => {
636 return Err(CoverageError::Invalid(p))
637 }
638 Err(AtError::Unlisted) => return Err(CoverageError::Unlisted),
639 };
640 let listing = git::stdout_bytes_in(
641 root,
642 &["ls-tree", "-r", "-z", "--full-tree", "--name-only", tree],
643 )
644 .filter(|l| !l.is_empty())
645 .ok_or(CoverageError::Unlisted)?;
646 let files: Vec<Vec<u8>> = listing
647 .split(|&b| b == 0)
648 .filter(|f| !f.is_empty())
649 .map(<[u8]>::to_vec)
650 .collect();
651 Ok(coverage(&spec, required, &files, |gate| {
652 crate::fingerprint::fingerprint(root, tree, &spec, gate).is_ok()
653 }))
654}