Skip to main content

git_attest/
spec.rs

1//! The `attest-inputs` declaration (SPEC.md, "Input fingerprints") and the
2//! canonical-tree opt-out it governs (SPEC.md, "Canonical tree").
3
4use std::path::Path;
5
6use crate::git;
7
8/// Where a repository declares which paths each gate reads, in order of
9/// precedence. Both are always part of every fingerprint's listing.
10pub 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// ---------------------------------------------------------------------------
16// Input fingerprints (1.3.0)
17// ---------------------------------------------------------------------------
18
19/// One declared path. `?src/build.rs` in the spec is `optional`: it may be
20/// absent, and its absence is then part of the fingerprint. The marker is
21/// stripped here, once, so no consumer can ever hand `?x` to git — where it
22/// would match nothing and silently drop out of the fingerprint.
23#[derive(Debug, PartialEq, Clone)]
24pub struct PathTok {
25    pub path: String,
26    pub optional: bool,
27}
28
29/// The committed declaration of which paths each gate reads.
30#[derive(Debug, PartialEq, Default)]
31#[non_exhaustive]
32pub struct Spec {
33    pub gates: Vec<(String, Vec<PathTok>)>,
34    /// `@plan-exempt off`: the repository opts out of the canonical tree.
35    pub plan_exempt_off: bool,
36}
37
38/// The one directive a spec may hold (1.6.0): the canonical opt-out.
39pub const PLAN_EXEMPT_DIRECTIVE: [&str; 2] = ["@plan-exempt", "off"];
40
41/// Is this spec line the opt-out directive? Blanks around and between the
42/// two words are tolerated, nothing else.
43pub 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
62/// A gate name as the spec allows it: `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`.
63pub(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/// Why a path token is not a literal, root-relative file or directory.
73/// `git ls-tree` does not glob, so a wildcard would match nothing and
74/// silently fingerprint nothing; every other shape here is a path git would
75/// interpret rather than look up.
76#[derive(Debug, Clone, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum PathProblem {
79    /// `?` alone.
80    NamesNothing,
81    /// Starts with `:`, git's pathspec magic.
82    Magic,
83    /// Starts with `/`.
84    Absolute,
85    /// Starts with `./` or `../`.
86    Relative,
87    /// Ends with `/`.
88    TrailingSlash,
89    /// Contains a glob or escape character.
90    Glob(char),
91    /// Has an empty, `.` or `..` component.
92    BadComponent,
93}
94
95/// Why a spec is invalid. Any one invalidates the whole spec. `Display` is
96/// the reason the verifier reports, after the spec's path.
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[non_exhaustive]
99pub enum Error {
100    /// Larger than [`MAX_SPEC_BYTES`].
101    TooLarge,
102    /// A byte outside printable ASCII, space, tab and LF.
103    Byte(u8),
104    /// A gate name outside `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`.
105    GateName { line: usize, gate: String },
106    /// A gate declared on two lines.
107    Duplicate { line: usize, gate: String },
108    /// A gate with no paths.
109    NoPaths { line: usize, gate: String },
110    /// A gate with more than [`MAX_SPEC_PATHS`] paths.
111    TooManyPaths { line: usize, gate: String },
112    /// A path that is not a literal, root-relative path.
113    Path {
114        line: usize,
115        path: String,
116        problem: PathProblem,
117    },
118    /// More than [`MAX_SPEC_GATES`] gates.
119    TooManyGates,
120    /// A line starting with `@` that is not `@plan-exempt off`.
121    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
181/// What is wrong with one path token, and the token as reported (without
182/// its `?` marker); `None` when it is a literal, root-relative path.
183pub(crate) fn path_error(tok: &str) -> Option<(String, PathProblem)> {
184    // One leading `?` marks an optional path; the rest obeys every rule
185    // below, so `??x` (a wildcard) and `?/x` (absolute) fail them.
186    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
219/// The spec grammar, byte-strict. Every violation is a distinct reason, and
220/// any violation invalidates the WHOLE spec: a line that silently dropped out
221/// would be one the author believes is protecting something.
222pub 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    // Every byte is ASCII by the check above.
233    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        // A directive, never a gate: an older parser reads `@` as an invalid
244        // gate name, which disables its fingerprint route and grants nothing.
245        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
306/// The paths every fingerprint lists besides the gate's own: both spec
307/// locations (so adding, removing or editing either invalidates),
308/// `.gitmodules`, and `.gitattributes` at the root and at every ancestor
309/// directory of every token — they change the bytes a checkout yields without
310/// changing any listed blob. Deduplicated; order does not affect the value.
311pub 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
332/// Does this `amont.conf` say `set planExempt off`? One tolerant reading,
333/// shared with amont and `verify.sh`: strip CR, trim blanks, split on blanks,
334/// and compare the three words `set`, `planExempt` and `off` ASCII
335/// case-insensitively. A line that is not exactly those three words (a
336/// trailing comment, another value) does not match; any match anywhere is off.
337pub 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/// What a tree says about the canonical route.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353#[non_exhaustive]
354pub enum PlanExempt {
355    /// Neither opt-out is committed.
356    On,
357    /// `@plan-exempt off` in the tree's `attest-inputs`.
358    Off,
359    /// Only `set planExempt off` in the tree's `amont.conf`: off, and
360    /// deprecated (read until 2.0.0).
361    OffByAmontConf,
362    /// A failing `ls-tree`, or a listed file whose blob cannot be read. Fails
363    /// narrow: callers treat it as off; only the log differs.
364    Unreadable,
365}
366
367impl PlanExempt {
368    /// Is the canonical route unavailable?
369    pub fn is_off(self) -> bool {
370        !matches!(self, PlanExempt::On)
371    }
372}
373
374/// The line a verifier reports when only the deprecated opt-out is present.
375pub const DEPRECATED_OPT_OUT: &str =
376    "deprecated: planExempt in amont.conf; also write @plan-exempt off in attest-inputs";
377
378/// Is the canonical route switched off for `tree`? Read from the committed
379/// bytes, never through a tool's trust-gated settings: `off` only narrows, so
380/// it is honoured whoever wrote it. The directive is found in either spec
381/// location even when the spec is otherwise invalid, for the same reason.
382pub 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/// Why a tree has a spec that cannot be used. Each disables the
416/// input-fingerprint route only.
417#[derive(Debug, Clone, PartialEq, Eq)]
418#[non_exhaustive]
419pub enum AtError {
420    /// Both locations hold a spec.
421    Ambiguous,
422    /// The spec at this path could not be read from the tree.
423    Unreadable(&'static str),
424    /// The spec at this path is invalid.
425    Invalid(&'static str, Error),
426    /// git could not list the tree, so whether it holds a spec is unknown.
427    Unlisted,
428}
429
430/// The spec committed in `tree` and where it was found; `Ok(None)` when the
431/// tree has none. Read from the TREE, never the working copy: the bytes
432/// hashed into every fingerprint and the bytes parsed here are then the same
433/// object. Presence comes from a listing that must itself succeed, never
434/// from a `cat-file -e` probe, which cannot tell an absent file from a git
435/// that failed: "absent" opens the canonical route, and "cannot tell" must
436/// not.
437pub 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
472// ---------------------------------------------------------------------------
473// Completeness: does a declaration cover every tracked file?
474// ---------------------------------------------------------------------------
475
476/// Paths no gate reads, exempt from the coverage check: the plans a change
477/// keeps rewriting. Everything else a repository tracks is either declared or
478/// the check fails.
479pub const EXEMPT: [&str; 1] = ["docs/plans/"];
480
481/// How many offending paths one gate lists before the rest are counted.
482const LIST_CAP: usize = 50;
483
484/// Why the coverage question cannot be answered. A guard that cannot answer
485/// fails, unlike the verifier, whose contract is fail-open.
486#[derive(Debug, Clone, PartialEq, Eq)]
487#[non_exhaustive]
488pub enum CoverageError {
489    /// The tree holds no spec.
490    Missing,
491    /// Both locations hold one.
492    Ambiguous,
493    /// The spec at this path is unreadable or invalid.
494    Invalid(&'static str),
495    /// git could not list the tree, or it is empty.
496    Unlisted,
497}
498
499impl CoverageError {
500    /// The one line a person reads when the spec itself cannot be used.
501    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/// What [`check_coverage`] found. Empty means every required gate declares
522/// every tracked file.
523#[derive(Debug, Default, Clone, PartialEq, Eq)]
524pub struct Findings {
525    /// Required gates the spec does not declare at all.
526    pub undeclared: Vec<String>,
527    /// Declared gates that cannot be fingerprinted on the tree.
528    pub unfingerprintable: Vec<String>,
529    /// Per gate, the tracked files no declared path is, or is an ancestor of.
530    pub uncovered: Vec<(String, Vec<String>)>,
531}
532
533impl Findings {
534    /// Everything wrong, for stderr: every gate and path, indented, then the
535    /// two ways out.
536    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
571/// The files every fingerprint lists whatever a gate declares: both spec
572/// locations, and the root `.gitmodules` and `.gitattributes`.
573fn 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
579/// Is `file` covered by the declared path `decl`: the file itself, or a file
580/// under that directory? Bytes, not text: a name outside printable ASCII can
581/// never be declared and is covered only through an ASCII ancestor.
582fn 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
587/// The pure half of [`check_coverage`]: coverage of `files` by each required
588/// gate of `spec`, with the fingerprint question answered by
589/// `can_fingerprint`.
590pub 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
619/// Do the required gates declare, completely, everything `tree` tracks?
620///
621/// Declared inputs are only as good as their completeness: a gate whose spec
622/// omits something it reads is skipped wrongly the day that something
623/// changes. This is the proof that closes the gap: per gate, by real
624/// ancestry, never by union and never by first path component. The spec is
625/// read from the tree, not the working copy.
626pub 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}