Skip to main content

git_attest/
verify.rs

1//! The verifier: given a repository and an `allowed_signers`, what does a
2//! valid attestation cover for the tree checked out there?
3//!
4//! `SPEC.md` is the contract; this module and `verify.sh` are two
5//! implementations of it, kept honest by `tests/conformance.sh`.
6//!
7//! Every failure is the same failure. No note, no signers file, an unreadable
8//! key, a tree that moved, a signature that does not verify — all mean "not
9//! covered", and not covered means the caller runs its tests. Nothing here can
10//! let an untested tree skip CI; it can only cost a redundant run. That is why
11//! this module returns verdicts rather than errors.
12
13use std::hash::{Hash, Hasher};
14use std::path::{Path, PathBuf};
15
16use crate::fingerprint::{canon, canon_key_in, fingerprint, key_in};
17use crate::format::{
18    canon_claim, field, input_fp, platform_matches, split_blocks, Names, GREEN, LEGACY,
19    LOCAL_PREFIX, MAX_BLOCKS,
20};
21use crate::git;
22use crate::notes::{sync_mirrors_of, Mirror};
23use crate::spec::{plan_exempt, AtError, PlanExempt, Spec, DEPRECATED_OPT_OUT, SPEC_PATHS};
24
25/// How many signatures one invocation verifies, over every candidate. A
26/// verification is one call of [`verify`]; blocks the cheap checks reject and
27/// blocks already judged do not count.
28pub const MAX_VERIFICATIONS: usize = 64;
29
30/// What the verifier concluded, and how it got there.
31///
32/// The trail is not decoration. Fail-open's worst property is silence: when a
33/// skip does not happen, nothing tells you whether the attestation was absent,
34/// stale, signed by another key, or minted on another platform. Carrying the
35/// reasons out of the same code path that made the decision is what keeps
36/// `explain` from becoming a second, drifting implementation of `covered`.
37#[non_exhaustive]
38pub struct Verdict {
39    /// Covered gate names, first-appearance order, no duplicates. Empty means
40    /// nothing is covered.
41    pub gates: Vec<String>,
42    pub trail: Vec<String>,
43    /// What became of `refs/notes/greengate-attest` and `…-inputs`, in that
44    /// order; `None` when the verifier stopped before reading any notes.
45    pub notes: Option<(Mirror, Mirror)>,
46    /// The same for `refs/notes/amont-attest` and `…-inputs`, the LEGACY
47    /// names that are read until the census.
48    pub legacy_notes: Option<(Mirror, Mirror)>,
49    /// The namespace of every name set a block verified under (`greengate-attest`,
50    /// `amont-attest`), first-appearance order. Shown even without `explain`:
51    /// it is the line the rollout counts.
52    pub verified_under: Vec<String>,
53    /// Lines that report a change to the repository's refs. They are shown
54    /// even without `explain`: data went away, and they say how to restore it.
55    pub loud: Vec<String>,
56    /// Lines that say something committed is deprecated, also in the trail
57    /// where they arose. Shown even without `explain`, so the repository
58    /// hears it before the old form stops working.
59    pub warnings: Vec<String>,
60}
61
62impl Verdict {
63    /// Nothing covered, for one reason, before any notes were read: what a
64    /// caller reports when it cannot even start (no signers file, say).
65    pub fn nothing(reason: impl Into<String>) -> Self {
66        Verdict {
67            gates: Vec::new(),
68            trail: vec![reason.into()],
69            notes: None,
70            legacy_notes: None,
71            verified_under: Vec::new(),
72            loud: Vec::new(),
73            warnings: Vec::new(),
74        }
75    }
76}
77
78/// Whether a notes ref holds a note for an object. Only `NoNote` sends a
79/// verifier to the next name set: a note that is there, readable or not,
80/// valid or not, is the one the object is judged by.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82enum Lookup {
83    NoNote,
84    Found,
85}
86
87/// Record a reason, unless it repeats the one before it.
88fn push_reason(trail: &mut Vec<String>, reason: String) {
89    if trail.last() != Some(&reason) {
90        trail.push(reason);
91    }
92}
93
94/// A lock a killed git left on a MIRROR fails every later update of it; say
95/// so, with the command, rather than lose coverage in silence.
96fn lock_reason(root: &Path, notes_ref: &str) -> Option<String> {
97    let lock = format!("refs/notes/{notes_ref}.lock");
98    let path = git::stdout_in(root, &["rev-parse", "--git-path", &lock])?;
99    // `--git-path` answers relative to where git ran.
100    let path = root.join(path).display().to_string();
101    Path::new(&path).exists().then(|| {
102        format!("a git that was killed left a lock on refs/notes/{notes_ref}; if no git is running: rm {path}")
103    })
104}
105
106/// The line that says why a mirror was not judged, if it was not.
107fn state_reason(notes_ref: &str, state: Mirror) -> Option<String> {
108    match state {
109        Mirror::Unreachable => Some(format!(
110            "cannot fetch refs/notes/{notes_ref} from origin (no credentials? persist-credentials: false?); local mirror not judged, running everything"
111        )),
112        Mirror::TimedOut => Some(format!(
113            "origin did not answer within {} s for refs/notes/{notes_ref}; local mirror not judged, running everything",
114            git::REMOTE_BUDGET_SECS
115        )),
116        Mirror::Unwritable => Some(format!(
117            "cannot update refs/notes/{notes_ref} to origin's copy; local mirror not judged"
118        )),
119        Mirror::Undeletable => Some(format!(
120            "origin has no refs/notes/{notes_ref} but the local mirror could not be deleted (read-only .git?); local mirror not judged"
121        )),
122        _ => None,
123    }
124}
125
126/// Where a repository keeps its `allowed_signers` when the caller does not say.
127///
128/// Resolved from the REPOSITORY ROOT, not the working directory. A workflow
129/// that sets `working-directory` — a monorepo matrix running inside
130/// `packages/<x>` — puts the step in a subdirectory, where a relative
131/// `.github/allowed_signers` does not exist. The verifier would then find no
132/// signers, cover nothing, and fail open FOREVER: the suite still runs, CI
133/// still passes, and nothing anywhere says the gate is dead.
134pub fn default_signers(dir: &Path) -> Option<PathBuf> {
135    let root = git::stdout_in(dir, &["rev-parse", "--show-toplevel"]).map(PathBuf::from);
136    [".forgejo/allowed_signers", ".github/allowed_signers"]
137        .into_iter()
138        .map(|rel| match &root {
139            Some(root) => root.join(rel),
140            None => dir.join(rel),
141        })
142        .find(|p| p.exists())
143}
144
145/// A caller-supplied signers path, resolved the way the default is: relative
146/// to the REPOSITORY ROOT, not the working directory. Absolute paths pass
147/// through.
148pub fn resolve_signers(dir: &Path, given: &str) -> PathBuf {
149    let path = PathBuf::from(given);
150    if path.is_absolute() {
151        return path;
152    }
153    match git::stdout_in(dir, &["rev-parse", "--show-toplevel"]) {
154        Some(root) => PathBuf::from(root).join(path),
155        None => dir.join(path),
156    }
157}
158
159/// Add names not already present, keeping first-appearance order.
160pub(crate) fn union<'a>(into: &mut Vec<String>, names: impl Iterator<Item = &'a str>) {
161    for name in names {
162        if !into.iter().any(|g| g == name) {
163            into.push(name.to_string());
164        }
165    }
166}
167
168/// The uniform rule, as a pure function: a gate of a verified block is
169/// covered if the block's tree IS the checked-out tree, or its canonical tree
170/// is the checked-out one's, or if the block claims a fingerprint for that gate
171/// equal to the one computed here.
172pub fn covered_gates<'a>(
173    tree_matches: bool,
174    canon_matches: bool,
175    gates: &'a str,
176    fp_of_block: impl Fn(&str) -> Option<&'a str>,
177    mut fp_head: impl FnMut(&str) -> Option<String>,
178) -> Vec<String> {
179    let mut out = Vec::new();
180    for g in gates.split_whitespace() {
181        let ok = tree_matches
182            || canon_matches
183            || match (fp_of_block(g), fp_head(g)) {
184                (Some(claimed), Some(here)) => claimed == here,
185                _ => false,
186            };
187        if ok {
188            union(&mut out, std::iter::once(g));
189        }
190    }
191    out
192}
193
194/// Everything one invocation accumulates while judging notes, so the same
195/// loop serves the object-keyed candidates and the fingerprint-keyed ones.
196struct Judge<'a> {
197    root: PathBuf,
198    head_tree: String,
199    signers: &'a Path,
200    principal: Option<&'a str>,
201    require_platform: Option<&'a str>,
202    anywhere: &'a [String],
203    spec: Option<Spec>,
204    fp_memo: Vec<(String, Option<String>)>,
205    canon_memo: Option<Option<String>>,
206    canon_used: bool,
207    warnings: Vec<String>,
208    covered: Vec<String>,
209    trail: Vec<String>,
210    seen_notes: Vec<String>,
211    seen_blocks: Vec<u64>,
212    verified_under: Vec<String>,
213    verifications: usize,
214    exhausted: bool,
215    tried: bool,
216}
217
218impl Judge<'_> {
219    /// The fingerprint of `gate` on the checked-out tree, computed once.
220    fn fp_head(&mut self, gate: &str) -> Option<String> {
221        if let Some((_, fp)) = self.fp_memo.iter().find(|(g, _)| g == gate) {
222            return fp.clone();
223        }
224        let fp = self
225            .spec
226            .as_ref()
227            .and_then(|s| fingerprint(&self.root, &self.head_tree, s, gate).ok());
228        self.fp_memo.push((gate.to_string(), fp.clone()));
229        fp
230    }
231
232    /// The canonical fingerprint of the checked-out tree, computed once; `None`
233    /// when the repository opted out (`set planExempt off`) or git failed.
234    fn canon_here(&mut self) -> Option<String> {
235        if let Some(c) = &self.canon_memo {
236            return c.clone();
237        }
238        let c = match plan_exempt(&self.root, &self.head_tree) {
239            how @ (PlanExempt::Off | PlanExempt::OffByAmontConf) => {
240                push_reason(
241                    &mut self.trail,
242                    "planExempt is off here; canonical trees are not used".into(),
243                );
244                if how == PlanExempt::OffByAmontConf {
245                    push_reason(&mut self.trail, DEPRECATED_OPT_OUT.into());
246                    union(&mut self.warnings, std::iter::once(DEPRECATED_OPT_OUT));
247                }
248                None
249            }
250            PlanExempt::Unreadable => {
251                push_reason(
252                    &mut self.trail,
253                    "cannot read amont.conf or attest-inputs; canonical trees are not used".into(),
254                );
255                None
256            }
257            PlanExempt::On => {
258                let c = canon(&self.root, &self.head_tree).ok();
259                if c.is_none() {
260                    push_reason(
261                        &mut self.trail,
262                        "cannot compute the canonical tree; canonical trees are not used".into(),
263                    );
264                }
265                c
266            }
267        };
268        self.canon_memo = Some(c.clone());
269        c
270    }
271
272    /// Judge every block of the note on `object` in `notes_ref`, which holds
273    /// notes of the `names` set and of no other: a block is checked under that
274    /// set's header, namespace and (through the key it was found by) domain.
275    /// Says whether the ref has a note for `object` at all, judged now, before,
276    /// or unreadable: the caller reads the next name set only on `NoNote`.
277    fn judge(&mut self, names: &Names, notes_ref: &str, object: &str, at: &str) -> Lookup {
278        let Some(listing) =
279            git::stdout_in(&self.root, &["notes", "--ref", notes_ref, "list", object])
280        else {
281            return Lookup::NoNote;
282        };
283        if self.exhausted {
284            return Lookup::Found;
285        }
286        let note_oid = listing.split_whitespace().next().unwrap_or("").to_string();
287        if self.seen_notes.contains(&note_oid) {
288            return Lookup::Found;
289        }
290        self.seen_notes.push(note_oid);
291        let Some(body) = git::stdout_in(&self.root, &["notes", "--ref", notes_ref, "show", object])
292        else {
293            // Listed but unreadable (a missing blob, say): a note is there, so
294            // the other name set is NOT read for this object.
295            push_reason(
296                &mut self.trail,
297                format!("cannot read the note on {at} in refs/notes/{notes_ref}"),
298            );
299            return Lookup::Found;
300        };
301        self.tried = true;
302
303        // LF only, the whole note: `verify.sh` applies the same test before
304        // any tool sees the bytes, because some awks drop carriage returns.
305        if body.contains('\r') {
306            push_reason(
307                &mut self.trail,
308                format!("note on {at} contains carriage returns; the format is LF-only"),
309            );
310            return Lookup::Found;
311        }
312
313        let (blocks, truncated) = split_blocks(&body);
314        if truncated {
315            push_reason(
316                &mut self.trail,
317                format!("note on {at} has more than {MAX_BLOCKS} blocks; the rest were ignored"),
318            );
319        }
320        if blocks.is_empty() {
321            push_reason(
322                &mut self.trail,
323                format!("note on {at} carries no signature block"),
324            );
325            return Lookup::Found;
326        }
327        let several = blocks.len() > 1;
328        for (i, block) in blocks.iter().enumerate() {
329            let where_ = if several {
330                format!("{at} block {}", i + 1)
331            } else {
332                at.to_string()
333            };
334            let payload = &block.payload;
335            if payload.split('\n').next() != Some(names.format) {
336                push_reason(
337                    &mut self.trail,
338                    format!(
339                        "note on {where_} is not {} — a newer producer, or the other name set, wrote it",
340                        names.format
341                    ),
342                );
343                continue;
344            }
345            let (Some(tree), Some(gates), Some(ran_on)) = (
346                field(payload, "tree"),
347                field(payload, "gates"),
348                field(payload, "platform"),
349            ) else {
350                push_reason(
351                    &mut self.trail,
352                    format!("note on {where_} is missing a required field"),
353                );
354                continue;
355            };
356            if gates.is_empty() {
357                push_reason(
358                    &mut self.trail,
359                    "attestation lists no gates — a signed way of saying nothing".into(),
360                );
361                continue;
362            }
363            // The same block reached through another key has the same
364            // verdict: its content and HEAD are all that decide it.
365            let mut h = std::collections::hash_map::DefaultHasher::new();
366            block.payload.hash(&mut h);
367            block.signature.hash(&mut h);
368            let bh = h.finish();
369            if self.seen_blocks.contains(&bh) {
370                continue;
371            }
372            self.seen_blocks.push(bh);
373            if self.verifications >= MAX_VERIFICATIONS {
374                push_reason(
375                    &mut self.trail,
376                    format!("the budget of {MAX_VERIFICATIONS} signature verifications is spent; remaining candidates were not read"),
377                );
378                self.exhausted = true;
379                return Lookup::Found;
380            }
381            self.verifications += 1;
382            // Signature BEFORE anything the block's claims could buy it: the
383            // `anywhere` list and the input fingerprints may only admit gates
384            // from a block that actually verified.
385            let signer = match crate::sign::verify(
386                names.namespace,
387                payload,
388                &block.signature,
389                self.signers,
390                self.principal,
391            ) {
392                Ok(signer) => signer,
393                Err(why) => {
394                    push_reason(&mut self.trail, format!("note on {where_}: {why}"));
395                    continue;
396                }
397            };
398            // The uniform rule, per gate: the tree is the checked-out tree,
399            // or the block's fingerprint for that gate is the one computed
400            // here.
401            let tree_matches = tree == self.head_tree;
402            // The canonical tree, for a block of another tree that claims
403            // one: asked of the checked-out tree, so a block filed under
404            // several keys is judged the same wherever it is first met.
405            let canon_matches = !tree_matches
406                && match (
407                    canon_claim(payload),
408                    canon_claim(payload).and_then(|_| self.canon_here()),
409                ) {
410                    (Some(claimed), Some(here)) => claimed == here,
411                    _ => false,
412                };
413            let kept = covered_gates(
414                tree_matches,
415                canon_matches,
416                gates,
417                |g| input_fp(payload, g),
418                |g| self.fp_head(g),
419            );
420            if kept.is_empty() {
421                push_reason(
422                    &mut self.trail,
423                    format!(
424                        "attested tree {tree} is not the checked-out tree {} and neither its canonical tree nor an input fingerprint of {where_} matches",
425                        self.head_tree
426                    ),
427                );
428                continue;
429            }
430            let how = if tree_matches {
431                ""
432            } else if canon_matches {
433                " (by canonical tree)"
434            } else {
435                " (by input fingerprint)"
436            };
437            let kept_str = kept.join(" ");
438            let verified_under = names.namespace;
439            // A pass is a pass ON SOMETHING: a macOS `cargo test` is no
440            // evidence about the Windows leg of a matrix.
441            if platform_matches(self.require_platform, ran_on) {
442                union(&mut self.covered, kept.iter().map(String::as_str));
443                union(&mut self.verified_under, std::iter::once(verified_under));
444                self.canon_used |= canon_matches;
445                push_reason(
446                    &mut self.trail,
447                    format!("covered by {signer} on {ran_on}{how}: {kept_str}"),
448                );
449                continue;
450            }
451            let accepted: Vec<&str> = kept
452                .iter()
453                .map(String::as_str)
454                .filter(|g| self.anywhere.iter().any(|a| a == g))
455                .collect();
456            union(&mut self.covered, accepted.iter().copied());
457            if !accepted.is_empty() {
458                union(&mut self.verified_under, std::iter::once(verified_under));
459            }
460            self.canon_used |= canon_matches && !accepted.is_empty();
461            let want = self.require_platform.unwrap_or("any");
462            let mut reason = format!("attested on {ran_on} by {signer}{how}, this leg is {want}");
463            if !accepted.is_empty() {
464                reason.push_str("; accepted anywhere: ");
465                reason.push_str(&accepted.join(" "));
466            }
467            push_reason(&mut self.trail, reason);
468        }
469        Lookup::Found
470    }
471}
472
473/// The spec at the checked-out tree, or the reason there is none to use.
474fn load_spec(root: &Path, head_tree: &str, trail: &mut Vec<String>) -> Option<Spec> {
475    match crate::spec::at(root, head_tree) {
476        Ok(found) => found.map(|(_, spec)| spec),
477        Err(AtError::Ambiguous) => {
478            push_reason(
479                trail,
480                format!(
481                    "both {} and {} exist; input fingerprints disabled",
482                    SPEC_PATHS[0], SPEC_PATHS[1]
483                ),
484            );
485            None
486        }
487        Err(AtError::Unreadable(path)) => {
488            push_reason(
489                trail,
490                format!("cannot read {path} from HEAD's tree; input fingerprints disabled"),
491            );
492            None
493        }
494        Err(AtError::Invalid(path, why)) => {
495            push_reason(trail, format!("{path} {why}; input fingerprints disabled"));
496            None
497        }
498        Err(AtError::Unlisted) => {
499            push_reason(
500                trail,
501                "cannot list HEAD's tree; input fingerprints disabled".into(),
502            );
503            None
504        }
505    }
506}
507
508/// The whole decision, trail included.
509///
510/// `principal` of `None` means "whoever the signature says, if that key is in
511/// the file"; `Some` narrows it to one identity. `require_platform` of `None`
512/// means the caller has stated this suite's result does not depend on where
513/// it ran. `anywhere` names gates the caller accepts from any platform — its
514/// committed statement that THOSE checks cannot depend on where they ran — and
515/// only ever admits gates from a block whose signature verified.
516pub fn evaluate(
517    dir: &Path,
518    signers: &Path,
519    principal: Option<&str>,
520    require_platform: Option<&str>,
521    anywhere: &[String],
522    include_local: bool,
523) -> Verdict {
524    let mut trail = Vec::new();
525    let mut loud = Vec::new();
526
527    if !signers.is_file() {
528        return Verdict::nothing(format!("{} does not exist", signers.display()));
529    }
530
531    // The repository first, as verify.sh does: outside one there is nothing
532    // to sync, and `notes=` stays empty.
533    let Some(root) = git::stdout_in(dir, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
534    else {
535        push_reason(&mut trail, "not a git repository".into());
536        return Verdict {
537            gates: Vec::new(),
538            trail,
539            notes: None,
540            legacy_notes: None,
541            verified_under: Vec::new(),
542            loud,
543            warnings: Vec::new(),
544        };
545    };
546
547    // Each name set is synced on its own: a set origin does not have is
548    // `Deleted` (nothing judged, nothing lost), never "unreachable".
549    //
550    // holds-until: Phase 4 writes greengate refs. Until then origin has no
551    // greengate ref, so the green fetch fails and costs an `ls-remote`, and the
552    // legacy set is a second fetch: up to twice the calls of 1.8.0.
553    let green_sync = sync_mirrors_of(&root, &GREEN);
554    let green = (green_sync.notes, green_sync.inputs);
555    let silent = green_sync.origin_silent();
556    loud.extend(green_sync.loud);
557    // An origin that did not answer for the first set will not for the
558    // second: asking again would only double the wait.
559    let legacy = match silent {
560        Some(state) => (state, state),
561        None => {
562            let legacy_sync = sync_mirrors_of(&root, &LEGACY);
563            loud.extend(legacy_sync.loud);
564            (legacy_sync.notes, legacy_sync.inputs)
565        }
566    };
567    let notes = Some(green);
568    let legacy_notes = Some(legacy);
569    let states = [(GREEN, green), (LEGACY, legacy)];
570    for (names, (notes_mirror, inputs_mirror)) in &states {
571        for (r, st) in [
572            (names.notes_ref, *notes_mirror),
573            (names.inputs_ref, *inputs_mirror),
574        ] {
575            if let Some(why) = state_reason(r, st) {
576                push_reason(&mut trail, why);
577            }
578            if let Some(why) = lock_reason(&root, r) {
579                push_reason(&mut trail, why);
580            }
581        }
582        if !include_local {
583            for r in [names.notes_ref, names.inputs_ref] {
584                let local = format!("refs/notes/{LOCAL_PREFIX}{r}");
585                // A ref that holds notes: `git notes remove` of the last one
586                // leaves the ref behind, and that is nothing to report.
587                if git::stdout_in(
588                    &root,
589                    &["notes", "--ref", &format!("{LOCAL_PREFIX}{r}"), "list"],
590                )
591                .is_some()
592                {
593                    push_reason(
594                        &mut trail,
595                        format!("{local} holds unpushed blocks, ignored without --include-local"),
596                    );
597                }
598            }
599        }
600    }
601    // The refs each lookup reads, per name set and in order: the mirror, then
602    // the unpushed. A local ref pairs with its own set's names only.
603    let refs = |mirror: &str, st: Mirror| -> Vec<String> {
604        let mut v = Vec::new();
605        if st.judged() {
606            v.push(mirror.to_string());
607        }
608        if include_local {
609            v.push(format!("{LOCAL_PREFIX}{mirror}"));
610        }
611        v
612    };
613    let sets: Vec<(Names, Vec<String>, Vec<String>)> = states
614        .iter()
615        .map(|(names, (notes_mirror, inputs_mirror))| {
616            (
617                *names,
618                refs(names.notes_ref, *notes_mirror),
619                refs(names.inputs_ref, *inputs_mirror),
620            )
621        })
622        .collect();
623
624    let Some(head_tree) = git::stdout_in(&root, &["rev-parse", "HEAD^{tree}"]) else {
625        push_reason(
626            &mut trail,
627            "cannot resolve HEAD^{tree} — not a git repository?".into(),
628        );
629        return Verdict {
630            gates: Vec::new(),
631            trail,
632            notes,
633            legacy_notes,
634            verified_under: Vec::new(),
635            loud,
636            warnings: Vec::new(),
637        };
638    };
639
640    let spec = load_spec(&root, &head_tree, &mut trail);
641    let mut judge = Judge {
642        root,
643        head_tree: head_tree.clone(),
644        signers,
645        principal,
646        require_platform,
647        anywhere,
648        spec,
649        fp_memo: Vec::new(),
650        canon_memo: None,
651        canon_used: false,
652        warnings: Vec::new(),
653        covered: Vec::new(),
654        trail,
655        seen_notes: Vec::new(),
656        seen_blocks: Vec::new(),
657        verified_under: Vec::new(),
658        verifications: 0,
659        exhausted: false,
660        tried: false,
661    };
662
663    // The TREE first: it is what the signature covers, so it is the only key
664    // that survives a squash-merge, an amend or a rebase. HEAD and HEAD^2
665    // follow for notes written by a producer that keyed by commit only —
666    // HEAD^2 because a PR checkout is a merge commit git made a moment ago,
667    // whose second parent is the pushed tip that carries the note.
668    //
669    // Per object, the green ref first; the legacy ref only when the green
670    // refs hold no note for it (a note is read whole under one name).
671    for candidate in [head_tree.as_str(), "HEAD", "HEAD^2"] {
672        let Some(object) = git::stdout_in(
673            &judge.root,
674            &["rev-parse", "--verify", "--quiet", candidate],
675        ) else {
676            continue;
677        };
678        for (names, refs_main, _) in &sets {
679            let mut found = false;
680            for r in refs_main {
681                found |= Lookup::Found == judge.judge(names, r, &object, &label(r, candidate));
682            }
683            if found {
684                break;
685            }
686        }
687    }
688
689    // Then, lazily, the fingerprint-keyed notes: only for gates the spec
690    // declares and nothing above covered. Each costs a few git processes and
691    // one notes lookup; the common case (the tree matched) costs nothing.
692    // Each name set computes its own key and reads its own inputs ref.
693    let spec_gates: Vec<String> = judge
694        .spec
695        .as_ref()
696        .map(|s| s.gates.iter().map(|(g, _)| g.clone()).collect())
697        .unwrap_or_default();
698    for gate in spec_gates {
699        if judge.exhausted || judge.covered.contains(&gate) {
700            continue;
701        }
702        let Some(fp) = judge.fp_head(&gate) else {
703            push_reason(
704                &mut judge.trail,
705                format!("gate {gate}: no fingerprint here (a declared path does not exist in this tree)"),
706            );
707            continue;
708        };
709        let at = format!("input {gate} {}", &fp[..12]);
710        for (names, _, refs_inputs) in &sets {
711            let Ok(k) = key_in(&judge.root, names, &gate, &fp) else {
712                continue;
713            };
714            let mut found = false;
715            for r in refs_inputs {
716                found |= Lookup::Found == judge.judge(names, r, &k, &label(r, &at));
717            }
718            if found {
719                break;
720            }
721        }
722    }
723
724    // Last, the canonical-tree key: one more note lookup, for a block signed
725    // on another tree whose canonical form is this one's.
726    if !judge.exhausted {
727        if let Some(c) = judge.canon_here() {
728            let at = format!("canon {}", &c[..12]);
729            for (names, _, refs_inputs) in &sets {
730                let Ok(k) = canon_key_in(&judge.root, names, &c) else {
731                    continue;
732                };
733                let mut found = false;
734                for r in refs_inputs {
735                    found |= Lookup::Found == judge.judge(names, r, &k, &label(r, &at));
736                }
737                if found {
738                    if judge.canon_used {
739                        push_reason(&mut judge.trail, format!("canonical tree {c}, key {k}"));
740                    }
741                    break;
742                }
743            }
744        }
745    }
746
747    if !judge.tried {
748        push_reason(
749            &mut judge.trail,
750            format!("no attestation found for tree {head_tree}"),
751        );
752    }
753    Verdict {
754        gates: judge.covered,
755        trail: judge.trail,
756        notes,
757        legacy_notes,
758        verified_under: judge.verified_under,
759        loud,
760        warnings: judge.warnings,
761    }
762}
763
764/// How a note is named in the trail: unpushed blocks say so.
765fn label(notes_ref: &str, at: &str) -> String {
766    if notes_ref.starts_with(LOCAL_PREFIX) {
767        format!("{at} (unpushed)")
768    } else {
769        at.to_string()
770    }
771}