Skip to main content

amont_runtime/
attest_inputs.rs

1//! The attest input-fingerprint grammar and hashing, shared by whoever needs to
2//! say "this gate's inputs are byte-identical to the ones it passed on".
3//!
4//! attest's SPEC.md, "Input fingerprints", is the contract: the grammar and
5//! the hashing are that repository's, ported here in behaviour — a fingerprint
6//! this crate writes must be the one its verifiers compute, byte for byte.
7//! Moved out of `attest.rs` so the gate-stamp cache can key on the same
8//! fingerprint without a second implementation to drift.
9
10/// Where a repository declares what each gate reads, in order of precedence.
11pub(crate) const SPEC_PATHS: [&str; 2] = [".forgejo/attest-inputs", ".github/attest-inputs"];
12
13// ---------------------------------------------------------------------------
14// Input fingerprints. attest's SPEC.md, "Input fingerprints": the grammar and
15// the hashing are that repository's contract, ported here verbatim in
16// behaviour — a fingerprint this hook writes must be the one its verifiers
17// compute, byte for byte.
18// ---------------------------------------------------------------------------
19
20const MAX_SPEC_BYTES: usize = 65536;
21const MAX_SPEC_GATES: usize = 64;
22const MAX_SPEC_PATHS: usize = 64;
23
24/// A gate name as the spec allows it: `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`.
25fn 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/// One declared path. `?build.rs` in the spec is `optional` (attest 1.4.0):
35/// it may be absent, and its absence is then part of the fingerprint. The
36/// marker is stripped here, once, so nothing downstream can hand `?x` to git
37/// — where it would match nothing and silently drop out of the listing.
38#[derive(Debug, Clone, PartialEq)]
39pub(crate) struct PathTok {
40    pub(crate) path: String,
41    pub(crate) optional: bool,
42}
43
44/// A path token that is not a literal, root-relative file or directory.
45/// `git ls-tree` does not glob, so a wildcard would fingerprint nothing. One
46/// leading `?` is the optional marker, not a wildcard; the rest obeys every
47/// rule, so `??x` and `?/x` are bad, and so is a lone `?`.
48fn 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
67/// The spec, byte-strict; any violation invalidates the WHOLE spec.
68pub(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
112/// The spec at `tree`, read from the tree (never the working copy). `None`
113/// when there is none, when both locations exist, or when it is invalid —
114/// the hook then attests by tree alone, as before.
115pub(crate) fn spec_at(tree: &str) -> Option<Vec<(String, Vec<PathTok>)>> {
116    read_spec(tree).ok().map(|(_, spec)| spec)
117}
118
119/// Why a spec cannot be used. The hooks collapse all three to "attest by tree
120/// alone"; the coverage check must say which one it is, because for a guard
121/// "no spec" and "a spec nobody can read" are both a failure to prove anything.
122#[derive(Debug, PartialEq)]
123pub enum SpecError {
124    Missing,
125    Ambiguous,
126    Invalid(&'static str),
127    /// git could not list the tree, or it is empty: nothing to cover.
128    Unlisted,
129}
130
131/// [`spec_at`] with the reason, and the path the spec was read from.
132pub(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
152/// Paths no gate reads, exempt from the coverage check: the plans this very
153/// change keeps rewriting. Everything else a repository tracks is either
154/// declared or the check fails.
155pub const EXEMPT: [&str; 1] = ["docs/plans/"];
156
157/// What [`check`] found. Empty means every required gate declares every
158/// tracked file.
159#[derive(Debug, Default, PartialEq)]
160pub struct Findings {
161    /// Required gates the spec does not declare at all.
162    pub undeclared: Vec<String>,
163    /// Declared gates that cannot be fingerprinted on the tree (a required
164    /// path is missing, or git failed).
165    pub unfingerprintable: Vec<String>,
166    /// Per gate, the tracked files no declared path is, or is an ancestor of.
167    pub uncovered: Vec<(String, Vec<String>)>,
168}
169
170/// How many offending paths one gate lists before the rest are counted.
171const LIST_CAP: usize = 50;
172
173impl SpecError {
174    /// The one line a person reads when the spec itself cannot be used.
175    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    /// Everything wrong, for stderr: every gate and path, indented, then the
197    /// two ways out.
198    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
233/// The files every fingerprint lists whatever a gate declares: both spec
234/// locations, and the root `.gitmodules` and `.gitattributes` (see
235/// [`implicit_inputs`]). Declaring them again would be noise.
236fn 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
242/// Is `file` covered by the declared path `decl`: the file itself, or a file
243/// under that directory? Bytes, not text: a name outside printable ASCII can
244/// never be declared (the grammar refuses it) and is covered only through an
245/// ASCII ancestor.
246fn 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
251/// Does any declared path in `toks` cover `file`: the file itself, or a
252/// directory above it?
253pub(crate) fn declares(toks: &[PathTok], file: &str) -> bool {
254    toks.iter().any(|t| covers(&t.path, file.as_bytes()))
255}
256
257/// The pure half of [`check`]: coverage of `files` by each required gate of
258/// `spec`, with the fingerprint question answered by `can_fingerprint`.
259pub(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
288/// Do the required gates declare, completely, everything `tree` tracks?
289///
290/// Declared inputs are only as good as their completeness: a gate whose spec
291/// omits something it reads is skipped wrongly the day that something changes
292/// (attest SPEC, "Input fingerprints": the consumer's declaration problem).
293/// This is the proof that closes the gap — per gate, by real ancestry, never
294/// by union and never by first path component. The spec is read from the tree,
295/// not the working copy.
296pub 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
312/// The paths every fingerprint lists besides the gate's own: both spec
313/// locations, `.gitmodules`, and `.gitattributes` at the root and at every
314/// ancestor directory of every declared path. Deduplicated.
315pub(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
342/// The fingerprint of `gate` on `tree`: `git hash-object` over the
343/// `ls-tree -r -z --full-tree` listing of the implicit paths and the gate's
344/// declared ones. `None` when a REQUIRED declared path does not resolve on
345/// that tree, when git fails at any step, or when the listing is empty. An
346/// optional path is listed when it exists and bound by its absence when not.
347pub(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
386/// The synthetic note key for (gate, fingerprint).
387pub(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}