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