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    // Presence comes from a listing that must itself succeed, never from a
134    // `cat-file -e` probe: that cannot tell an absent file from a git that
135    // failed, and "absent" opens the canonical route while "cannot tell" must
136    // not.
137    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
170/// Paths no gate reads, exempt from the coverage check: the plans this very
171/// change keeps rewriting. Everything else a repository tracks is either
172/// declared or the check fails.
173pub const EXEMPT: [&str; 1] = ["docs/plans/"];
174
175/// What [`check`] found. Empty means every required gate declares every
176/// tracked file.
177#[derive(Debug, Default, PartialEq)]
178pub struct Findings {
179    /// Required gates the spec does not declare at all.
180    pub undeclared: Vec<String>,
181    /// Declared gates that cannot be fingerprinted on the tree (a required
182    /// path is missing, or git failed).
183    pub unfingerprintable: Vec<String>,
184    /// Per gate, the tracked files no declared path is, or is an ancestor of.
185    pub uncovered: Vec<(String, Vec<String>)>,
186}
187
188/// How many offending paths one gate lists before the rest are counted.
189const LIST_CAP: usize = 50;
190
191impl SpecError {
192    /// The one line a person reads when the spec itself cannot be used.
193    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    /// Everything wrong, for stderr: every gate and path, indented, then the
215    /// two ways out.
216    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
251/// The files every fingerprint lists whatever a gate declares: both spec
252/// locations, and the root `.gitmodules` and `.gitattributes` (see
253/// [`implicit_inputs`]). Declaring them again would be noise.
254fn 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
260/// Is `file` covered by the declared path `decl`: the file itself, or a file
261/// under that directory? Bytes, not text: a name outside printable ASCII can
262/// never be declared (the grammar refuses it) and is covered only through an
263/// ASCII ancestor.
264fn 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
269/// Does any declared path in `toks` cover `file`: the file itself, or a
270/// directory above it?
271pub(crate) fn declares(toks: &[PathTok], file: &str) -> bool {
272    toks.iter().any(|t| covers(&t.path, file.as_bytes()))
273}
274
275/// The pure half of [`check`]: coverage of `files` by each required gate of
276/// `spec`, with the fingerprint question answered by `can_fingerprint`.
277pub(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
306/// Do the required gates declare, completely, everything `tree` tracks?
307///
308/// Declared inputs are only as good as their completeness: a gate whose spec
309/// omits something it reads is skipped wrongly the day that something changes
310/// (attest SPEC, "Input fingerprints": the consumer's declaration problem).
311/// This is the proof that closes the gap — per gate, by real ancestry, never
312/// by union and never by first path component. The spec is read from the tree,
313/// not the working copy.
314pub 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
330/// The paths every fingerprint lists besides the gate's own: both spec
331/// locations, `.gitmodules`, and `.gitattributes` at the root and at every
332/// ancestor directory of every declared path. Deduplicated.
333pub(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
360/// The fingerprint of `gate` on `tree`: `git hash-object` over the
361/// `ls-tree -r -z --full-tree` listing of the implicit paths and the gate's
362/// declared ones. `None` when a REQUIRED declared path does not resolve on
363/// that tree, when git fails at any step, or when the listing is empty. An
364/// optional path is listed when it exists and bound by its absence when not.
365pub(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
404/// The synthetic note key for (gate, fingerprint).
405pub(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
415// ---------------------------------------------------------------------------
416// The canonical tree (attest 1.5.0). attest's SPEC.md, "Canonical tree": the
417// fingerprint of a tree in which the CONTENT of every regular file under the
418// top-level `docs/plans/` is not counted but its EXISTENCE is. Nothing is
419// declared, so nothing can be under-declared. Ported in behaviour from the
420// shell and Rust verifiers there; the vectors below pin the same cases.
421// ---------------------------------------------------------------------------
422
423/// Does this `amont.conf` say `set planExempt off`? One tolerant reading,
424/// shared with attest: strip CR, trim blanks, split on blanks, compare the
425/// three words `set`, `planExempt` and `off` ASCII case-insensitively. A line
426/// that is not exactly those three words does not match; any match is off.
427pub(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
441/// Is the canonical route switched off for `tree`? Read from the bytes of
442/// `tree:amont.conf` itself, never through the trust-gated settings: `off`
443/// only narrows, so it is honoured whoever wrote it. Fails narrow: a failing
444/// `ls-tree`, or a listed `amont.conf` whose blob cannot be read, is off; only
445/// a successful `ls-tree` that does not list the file means on.
446pub(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
461/// The canonical text of an `ls-tree -r --full-tree` listing (paths quoted by
462/// `core.quotePath=true`, so one record is one line): the same lines, except
463/// that a regular file (`100644`, `100755`) under the top-level `docs/plans/`
464/// has its oid replaced by zeros of the same length. Symlinks and gitlinks are
465/// never neutralised. `None` for an empty or malformed listing.
466pub(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        // A quoted path keeps its first characters: `"docs/plans/...`.
476        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
490/// The canonical fingerprint of `tree`, or `None` when git fails at any step.
491pub(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
518/// The synthetic key of a canonical fingerprint in attest's inputs ref.
519pub(crate) fn canon_key(canon: &str) -> Option<String> {
520    hashed(&format!("amont-attest-canon {canon}\n"))
521}
522
523/// The key under which the local gate stamps of a canonical fingerprint are
524/// filed in amont's own inputs ref: one note per fingerprint, holding the
525/// tokens of every gate that passed on it.
526pub(crate) fn canon_gate_key(canon: &str) -> Option<String> {
527    hashed(&format!("amont-gate-canon {canon}\n"))
528}
529
530/// The canonical fingerprint of `tree` when its gates are reused canonically:
531/// no spec at all (a successful listing that lists neither location), the
532/// repository has not opted out, and git could compute it. An invalid,
533/// ambiguous or unlistable spec is today's fail-open: no canonical route, so
534/// no canonical stamps.
535pub(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
543/// The canonical fingerprint a pushed block may carry: a repository with no
544/// spec, or a valid one (which keeps its `input` lines and its precedence for
545/// its own gates). Not an invalid, ambiguous or unlistable one.
546pub(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    /// One case of the known answers: a name, a listing, and its canonical
617    /// text (`None`: no canon).
618    type CanonCase = (String, String, Option<String>);
619
620    /// Parse `tests/vectors/canon.txt`, vendored byte for byte from attest's
621    /// `tests/vectors/canon.txt` (its header says the format). Strict: any
622    /// line the format does not allow panics, so a damaged copy fails the
623    /// test instead of silently testing less.
624    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        // A failing ls-tree is not an absent amont.conf: fail narrow.
703        assert!(plan_exempt_off(bogus));
704    }
705}