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 present: Vec<&'static str> = SPEC_PATHS
134 .iter()
135 .copied()
136 .filter(|p| crate::git::succeeds(&["cat-file", "-e", &format!("{tree}:{p}")]))
137 .collect();
138 let path = match present.as_slice() {
139 [] => return Err(SpecError::Missing),
140 [path] => *path,
141 _ => return Err(SpecError::Ambiguous),
142 };
143 let bytes = crate::git::stdout_raw(&["cat-file", "blob", &format!("{tree}:{path}")])
144 .ok_or(SpecError::Invalid(path))?;
145 parse_spec(&bytes)
146 .map(|spec| (path, spec))
147 .ok_or(SpecError::Invalid(path))
148}
149
150pub(crate) type Spec = Vec<(String, Vec<PathTok>)>;
151
152pub const EXEMPT: [&str; 1] = ["docs/plans/"];
156
157#[derive(Debug, Default, PartialEq)]
160pub struct Findings {
161 pub undeclared: Vec<String>,
163 pub unfingerprintable: Vec<String>,
166 pub uncovered: Vec<(String, Vec<String>)>,
168}
169
170const LIST_CAP: usize = 50;
172
173impl SpecError {
174 pub fn render(&self, tree: &str) -> String {
176 match self {
177 SpecError::Missing => format!(
178 "check-attest-inputs: no {} or {} on {tree}",
179 SPEC_PATHS[0], SPEC_PATHS[1]
180 ),
181 SpecError::Ambiguous => format!(
182 "check-attest-inputs: both {} and {} exist on {tree}; that disables the route",
183 SPEC_PATHS[0], SPEC_PATHS[1]
184 ),
185 SpecError::Invalid(path) => format!(
186 "check-attest-inputs: {path} is invalid on {tree} (one violation invalidates the whole spec; see attest SPEC, \"Input fingerprints\")"
187 ),
188 SpecError::Unlisted => {
189 format!("check-attest-inputs: git could not list {tree}, or it is empty")
190 }
191 }
192 }
193}
194
195impl Findings {
196 pub fn render(&self) -> String {
199 let mut out = String::new();
200 for g in &self.undeclared {
201 out.push_str(&format!(
202 "check-attest-inputs: {g}: not declared in the spec\n"
203 ));
204 }
205 for g in &self.unfingerprintable {
206 out.push_str(&format!(
207 "check-attest-inputs: {g}: cannot be fingerprinted (a required path is missing)\n"
208 ));
209 }
210 for (g, paths) in &self.uncovered {
211 out.push_str(&format!("check-attest-inputs: {g}: not covered:\n"));
212 for p in paths.iter().take(LIST_CAP) {
213 out.push_str(&format!(" {p}\n"));
214 }
215 if paths.len() > LIST_CAP {
216 out.push_str(&format!(" ... and {} more\n", paths.len() - LIST_CAP));
217 }
218 }
219 out.push_str(
220 "check-attest-inputs: fix: declare each path above for that gate (a directory \
221 covers everything under it); only a path no gate reads may be exempt, and that \
222 is a code-review decision. An undeclared path makes CI and the hooks skip \
223 wrongly the day it changes.\n",
224 );
225 out
226 }
227
228 pub fn is_empty(&self) -> bool {
229 self.undeclared.is_empty() && self.unfingerprintable.is_empty() && self.uncovered.is_empty()
230 }
231}
232
233fn is_implicit(file: &[u8]) -> bool {
237 SPEC_PATHS.iter().any(|p| p.as_bytes() == file)
238 || file == b".gitmodules"
239 || file == b".gitattributes"
240}
241
242fn covers(decl: &str, file: &[u8]) -> bool {
247 let d = decl.as_bytes();
248 file == d || (file.len() > d.len() && file.starts_with(d) && file[d.len()] == b'/')
249}
250
251pub(crate) fn declares(toks: &[PathTok], file: &str) -> bool {
254 toks.iter().any(|t| covers(&t.path, file.as_bytes()))
255}
256
257pub(crate) fn coverage(
260 spec: &Spec,
261 required: &[String],
262 files: &[Vec<u8>],
263 can_fingerprint: impl Fn(&[PathTok]) -> bool,
264) -> Findings {
265 let mut out = Findings::default();
266 for gate in required {
267 let Some((_, toks)) = spec.iter().find(|(g, _)| g == gate) else {
268 out.undeclared.push(gate.clone());
269 continue;
270 };
271 if !can_fingerprint(toks) {
272 out.unfingerprintable.push(gate.clone());
273 }
274 let missing: Vec<String> = files
275 .iter()
276 .filter(|f| !EXEMPT.iter().any(|e| f.starts_with(e.as_bytes())))
277 .filter(|f| !is_implicit(f))
278 .filter(|f| !toks.iter().any(|t| covers(&t.path, f)))
279 .map(|f| String::from_utf8_lossy(f).into_owned())
280 .collect();
281 if !missing.is_empty() {
282 out.uncovered.push((gate.clone(), missing));
283 }
284 }
285 out
286}
287
288pub fn check(tree: &str, required: &[String]) -> Result<Findings, SpecError> {
297 let (_, spec) = read_spec(tree)?;
298 let listing =
299 crate::git::stdout_raw(&["ls-tree", "-r", "-z", "--full-tree", "--name-only", tree])
300 .filter(|l| !l.is_empty())
301 .ok_or(SpecError::Unlisted)?;
302 let files: Vec<Vec<u8>> = listing
303 .split(|&b| b == 0)
304 .filter(|f| !f.is_empty())
305 .map(<[u8]>::to_vec)
306 .collect();
307 Ok(coverage(&spec, required, &files, |toks| {
308 fingerprint(tree, toks).is_some()
309 }))
310}
311
312pub(crate) fn implicit_inputs(tokens: &[String]) -> Vec<String> {
316 let mut attrs: Vec<String> = vec![".gitattributes".to_string()];
317 for t in tokens {
318 let comps: Vec<&str> = t.split('/').collect();
319 let mut prefix = String::new();
320 for c in &comps[..comps.len().saturating_sub(1)] {
321 if !prefix.is_empty() {
322 prefix.push('/');
323 }
324 prefix.push_str(c);
325 attrs.push(format!("{prefix}/.gitattributes"));
326 }
327 }
328 attrs.sort();
329 attrs.dedup();
330 let mut out: Vec<String> = SPEC_PATHS.iter().map(|s| s.to_string()).collect();
331 out.push(".gitmodules".to_string());
332 out.extend(attrs);
333 out
334}
335
336pub(crate) fn is_oid(s: &str) -> bool {
337 (s.len() == 40 || s.len() == 64)
338 && s.bytes()
339 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
340}
341
342pub(crate) fn fingerprint(tree: &str, toks: &[PathTok]) -> Option<String> {
348 let required: Vec<&str> = toks
349 .iter()
350 .filter(|t| !t.optional)
351 .map(|t| t.path.as_str())
352 .collect();
353 if !required.is_empty() {
354 let names: String = required.iter().map(|p| format!("{tree}:{p}\n")).collect();
355 let answers = crate::git::stdout_piped(&["cat-file", "--batch-check"], &names)?;
356 if answers.lines().count() != required.len()
357 || answers.lines().any(|l| l.ends_with(" missing"))
358 {
359 return None;
360 }
361 }
362 let paths: Vec<String> = toks.iter().map(|t| t.path.clone()).collect();
363 let mut args: Vec<String> = vec![
364 "ls-tree".into(),
365 "-r".into(),
366 "-z".into(),
367 "--full-tree".into(),
368 tree.to_string(),
369 "--".into(),
370 ];
371 args.extend(implicit_inputs(&paths));
372 args.extend(paths.iter().cloned());
373 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
374 let listing = crate::git::stdout_raw(&argv)?;
375 if listing.is_empty() {
376 return None;
377 }
378 crate::git::stdout_piped_in(
379 std::path::Path::new("."),
380 &["hash-object", "--stdin"],
381 &listing,
382 )
383 .filter(|s| is_oid(s))
384}
385
386pub(crate) fn input_key(gate: &str, fp: &str) -> Option<String> {
388 let pre = format!("amont-attest-input {gate} {fp}\n");
389 crate::git::stdout_piped_in(
390 std::path::Path::new("."),
391 &["hash-object", "--stdin"],
392 pre.as_bytes(),
393 )
394 .filter(|s| is_oid(s))
395}