Skip to main content

speccoder_runtime/attest/
verify.rs

1//! `speccoder attest verify`: re-checks every task's attestation against
2//! the commit it is about — signatures with the signer's own tool, against
3//! the verifier's trust, never the project's.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::io::Write as _;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9
10use base64::Engine as _;
11use base64::engine::general_purpose::STANDARD;
12use serde_json::Value;
13use speccoder_core::{
14    AttestReport, CommandData, CommandResult, CommandStatus, ObjectVerification, SignerView,
15    VerifyOutcome, VerifyReason,
16};
17
18use super::dsse::{self, Envelope};
19use super::{notes, statement};
20use crate::git::Git;
21
22/// Where trust comes from: the verifier's, never the project's.
23#[derive(Debug, Clone, Default)]
24pub struct Trust {
25    /// SSH allowed-signers file (`--allowed-signers`), else git's
26    /// `gpg.ssh.allowedSignersFile`.
27    pub allowed_signers: Option<PathBuf>,
28    /// The Sigstore certificate identity expected.
29    pub identity: Option<String>,
30    /// The Sigstore OIDC issuer expected.
31    pub issuer: Option<String>,
32}
33
34/// One object to verify.
35#[derive(Debug, Clone)]
36pub struct Target {
37    /// The commit (or skip record) id.
38    pub object: String,
39    /// `task` or `skip`.
40    pub kind: &'static str,
41    /// Task ID.
42    pub task: String,
43}
44
45fn reason(code: &str) -> VerifyReason {
46    VerifyReason {
47        code: code.to_owned(),
48        field: None,
49        expected: None,
50        found: None,
51    }
52}
53
54fn differs(code: &str, field: &str, expected: &str, found: &str) -> VerifyReason {
55    VerifyReason {
56        code: code.to_owned(),
57        field: Some(field.to_owned()),
58        expected: Some(expected.to_owned()),
59        found: Some(found.to_owned()),
60    }
61}
62
63/// A signature check's result.
64enum Checked {
65    Good(SignerView),
66    Untrusted(SignerView),
67    Bad(String),
68    /// The signer's tool cannot be spawned: nothing can be said.
69    ToolMissing(&'static str),
70}
71
72fn run(mut cmd: Command, input: Option<&[u8]>) -> Option<(bool, String, String)> {
73    let mut child = cmd
74        .stdin(if input.is_some() {
75            Stdio::piped()
76        } else {
77            Stdio::null()
78        })
79        .stdout(Stdio::piped())
80        .stderr(Stdio::piped())
81        .spawn()
82        .ok()?;
83    if let (Some(data), Some(mut stdin)) = (input, child.stdin.take()) {
84        let _ = stdin.write_all(data);
85    }
86    let out = child.wait_with_output().ok()?;
87    Some((
88        out.status.success(),
89        String::from_utf8_lossy(&out.stdout).into_owned(),
90        String::from_utf8_lossy(&out.stderr).into_owned(),
91    ))
92}
93
94fn check_ssh(armored: &[u8], data: &[u8], keyid: &str, allowed: Option<&Path>) -> Checked {
95    let Ok(dir) = tempfile::tempdir() else {
96        return Checked::Bad("no temporary directory".to_owned());
97    };
98    let sig = dir.path().join("line.sig");
99    if std::fs::write(&sig, armored).is_err() {
100        return Checked::Bad("cannot write the signature".to_owned());
101    }
102    let view = |principal: Option<String>| SignerView {
103        kind: "ssh".to_owned(),
104        keyid: keyid.to_owned(),
105        principal,
106    };
107    if let Some(allowed) = allowed {
108        let mut find = Command::new("ssh-keygen");
109        find.args(["-Y", "find-principals", "-s"])
110            .arg(&sig)
111            .arg("-f")
112            .arg(allowed);
113        if let Some((true, out, _)) = run(find, None)
114            && let Some(principal) = out.lines().next().map(str::to_owned)
115        {
116            let mut verify = Command::new("ssh-keygen");
117            verify
118                .args(["-Y", "verify", "-f"])
119                .arg(allowed)
120                .args(["-I", &principal, "-n", super::sign::NAMESPACE, "-s"])
121                .arg(&sig);
122            return match run(verify, Some(data)) {
123                Some((true, ..)) => Checked::Good(view(Some(principal))),
124                Some((false, _, err)) => Checked::Bad(err.trim().to_owned()),
125                None => Checked::ToolMissing("ssh-keygen"),
126            };
127        }
128    }
129    // Nobody the verifier trusts: is it at least a valid signature?
130    let mut check = Command::new("ssh-keygen");
131    check
132        .args(["-Y", "check-novalidate", "-n", super::sign::NAMESPACE, "-s"])
133        .arg(&sig);
134    match run(check, Some(data)) {
135        Some((true, ..)) => Checked::Untrusted(view(None)),
136        Some((false, _, err)) => Checked::Bad(err.trim().to_owned()),
137        None => Checked::ToolMissing("ssh-keygen"),
138    }
139}
140
141fn check_openpgp(armored: &[u8], data: &[u8], keyid: &str) -> Checked {
142    let Ok(dir) = tempfile::tempdir() else {
143        return Checked::Bad("no temporary directory".to_owned());
144    };
145    let sig = dir.path().join("line.asc");
146    let body = dir.path().join("line.pae");
147    if std::fs::write(&sig, armored).is_err() || std::fs::write(&body, data).is_err() {
148        return Checked::Bad("cannot write the signature".to_owned());
149    }
150    let mut cmd = Command::new("gpg");
151    cmd.args(["--batch", "--status-fd", "1", "--verify"])
152        .arg(&sig)
153        .arg(&body);
154    let Some((ok, status, _)) = run(cmd, None) else {
155        return Checked::ToolMissing("gpg");
156    };
157    let view = |fingerprint: Option<String>, uid: Option<String>| SignerView {
158        kind: "openpgp".to_owned(),
159        keyid: fingerprint.unwrap_or_else(|| keyid.to_owned()),
160        principal: uid,
161    };
162    match gpg_status(&status, ok) {
163        Gpg::Bad => Checked::Bad("gpg: BAD signature".to_owned()),
164        Gpg::Good { fingerprint, uid } => Checked::Good(view(Some(fingerprint), uid)),
165        Gpg::Untrusted { fingerprint, uid } => Checked::Untrusted(view(fingerprint, uid)),
166    }
167}
168
169/// What gpg's `--status-fd` lines say about a detached signature.
170#[derive(Debug, PartialEq, Eq)]
171enum Gpg {
172    /// A signature that does not verify.
173    Bad,
174    /// One good signature by a key the keyring trusts fully or ultimately.
175    Good {
176        /// The signing key's fingerprint (`VALIDSIG`).
177        fingerprint: String,
178        /// The user ID `GOODSIG` names (the signer's own text).
179        uid: Option<String>,
180    },
181    /// Anything else: no key here, an expired or revoked key, a key not
182    /// trusted, or status lines that do not add up to one good signature.
183    Untrusted {
184        /// The signing key's fingerprint, when gpg gave one.
185        fingerprint: Option<String>,
186        /// The user ID, when gpg gave one.
187        uid: Option<String>,
188    },
189}
190
191/// Reads gpg's status lines exactly: a line counts only when it starts with
192/// `[GNUPG:] ` and its keyword (the first token after) is the one asked for
193/// — never a substring, since the user ID on `GOODSIG` is the signer's text.
194/// Good takes gpg's success, exactly one `GOODSIG`, one `VALIDSIG` whose
195/// fingerprint ends in that `GOODSIG`'s key ID, and one trust line that is
196/// `TRUST_FULLY` or `TRUST_ULTIMATE`, with no bad, erroneous, expired or
197/// revoked signature beside them.
198fn gpg_status(status: &str, exited_ok: bool) -> Gpg {
199    let mut good: Vec<(&str, Option<&str>)> = Vec::new();
200    let mut valid: Vec<&str> = Vec::new();
201    let mut trust: Vec<&str> = Vec::new();
202    let mut spoiled = false;
203    for line in status.lines() {
204        let Some(rest) = line.strip_prefix("[GNUPG:] ") else {
205            continue;
206        };
207        let (keyword, args) = rest.split_once(' ').unwrap_or((rest, ""));
208        match keyword {
209            "BADSIG" => return Gpg::Bad,
210            "GOODSIG" => {
211                let (id, uid) = args
212                    .split_once(' ')
213                    .map_or((args, None), |(id, uid)| (id, Some(uid)));
214                good.push((id, uid));
215            }
216            "VALIDSIG" => valid.push(args.split(' ').next().unwrap_or_default()),
217            "TRUST_UNDEFINED" | "TRUST_NEVER" | "TRUST_MARGINAL" | "TRUST_FULLY"
218            | "TRUST_ULTIMATE" => trust.push(keyword),
219            "ERRSIG" | "EXPSIG" | "EXPKEYSIG" | "REVKEYSIG" | "NO_PUBKEY" => spoiled = true,
220            _ => {}
221        }
222    }
223    let uid = good.first().and_then(|(_, u)| u.map(str::to_owned));
224    let fingerprint = valid
225        .first()
226        .filter(|f| !f.is_empty())
227        .map(|f| (*f).to_owned());
228    let one_good = match (&good[..], &valid[..], &trust[..]) {
229        ([(id, _)], [fpr], [level]) => {
230            !id.is_empty()
231                && fpr.len() >= id.len()
232                && fpr.to_ascii_uppercase().ends_with(&id.to_ascii_uppercase())
233                && matches!(*level, "TRUST_FULLY" | "TRUST_ULTIMATE")
234        }
235        _ => false,
236    };
237    match fingerprint {
238        Some(fingerprint) if exited_ok && one_good && !spoiled => Gpg::Good { fingerprint, uid },
239        fingerprint => Gpg::Untrusted { fingerprint, uid },
240    }
241}
242
243fn check_sigstore(line: &Envelope, data: &[u8], trust: &Trust) -> Checked {
244    let view = SignerView {
245        kind: "sigstore".to_owned(),
246        keyid: "sigstore".to_owned(),
247        principal: trust.identity.clone(),
248    };
249    let (Some(material), Some(identity), Some(issuer)) =
250        (&line.sigstore, &trust.identity, &trust.issuer)
251    else {
252        return Checked::Untrusted(view);
253    };
254    let Ok(dir) = tempfile::tempdir() else {
255        return Checked::Bad("no temporary directory".to_owned());
256    };
257    let blob = dir.path().join("line.pae");
258    let bundle = dir.path().join("line.sigstore.json");
259    if std::fs::write(&blob, data).is_err()
260        || std::fs::write(&bundle, material.to_string()).is_err()
261    {
262        return Checked::Bad("cannot write the bundle".to_owned());
263    }
264    let mut cmd = Command::new("cosign");
265    cmd.args(["verify-blob", "--bundle"])
266        .arg(&bundle)
267        .args([
268            "--certificate-identity",
269            identity,
270            "--certificate-oidc-issuer",
271            issuer,
272        ])
273        .arg(&blob);
274    match run(cmd, None) {
275        Some((true, ..)) => Checked::Good(view),
276        Some((false, _, err)) => Checked::Bad(err.trim().to_owned()),
277        None => Checked::ToolMissing("cosign"),
278    }
279}
280
281fn check_line(line: &Envelope, trust: &Trust) -> Option<Checked> {
282    let sig = line.signatures.first()?;
283    let Some(data) = line.signed_bytes() else {
284        return Some(Checked::Bad("the payload is not base64".to_owned()));
285    };
286    if line.sigstore.is_some() {
287        return Some(check_sigstore(line, &data, trust));
288    }
289    let Ok(armored) = STANDARD.decode(&sig.sig) else {
290        return Some(Checked::Bad("the signature is not base64".to_owned()));
291    };
292    let text = String::from_utf8_lossy(&armored);
293    Some(if text.contains("BEGIN SSH SIGNATURE") {
294        check_ssh(
295            &armored,
296            &data,
297            &sig.keyid,
298            trust.allowed_signers.as_deref(),
299        )
300    } else if text.contains("BEGIN PGP SIGNATURE") {
301        check_openpgp(&armored, &data, &sig.keyid)
302    } else {
303        Checked::Bad("an unknown signature format".to_owned())
304    })
305}
306
307/// The commit's trailers by key.
308fn trailers(repo: &Path, object: &str) -> BTreeMap<String, String> {
309    let text = Git::at(repo)
310        .run(&["log", "-1", "--format=%(trailers:only,unfold)", object])
311        .map(|b| String::from_utf8_lossy(&b).into_owned())
312        .unwrap_or_default();
313    text.lines()
314        .filter_map(|l| l.split_once(": "))
315        .map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned()))
316        .collect()
317}
318
319/// The task line's hash in the tasks file at the commit, with its box read
320/// as open (the hash a statement names was taken before the tick).
321fn line_hash_at(cx: &Context<'_>, object: &str, tasks_path: &str, task: &str) -> Option<String> {
322    let text = Git::at(cx.repo)
323        .run(&["show", &format!("{object}:{tasks_path}")])
324        .ok()
325        .map(|b| String::from_utf8_lossy(&b).into_owned())?;
326    let format = cx.format.unwrap_or_else(|| crate::format::at(cx.repo));
327    let (parsed, _) = format.parse_tasks(tasks_path, &text);
328    let t = parsed.tasks.iter().find(|t| t.id == task)?;
329    let line = text
330        .lines()
331        .nth(usize::try_from(t.line).ok()?.checked_sub(1)?)?;
332    Some(crate::integrity::sha256_hex(&crate::integrity::untick(
333        line.as_bytes(),
334    )))
335}
336
337/// Everything that decides an object's outcome, given the trust input.
338pub struct Context<'a> {
339    /// The repository.
340    pub repo: &'a Path,
341    /// The feature.
342    pub feature: &'a str,
343    /// The tasks file's path in the repository, when tracked.
344    pub tasks_path: Option<&'a str>,
345    /// `speccoder.toml`'s path in the repository.
346    pub config_path: &'a str,
347    /// The verifier's trust.
348    pub trust: &'a Trust,
349    /// The format reading the task lists, when the caller knows it (read
350    /// from git objects); `None`: the one of the checkout at `repo`.
351    pub format: Option<&'static dyn crate::format::Adapter>,
352}
353
354/// Verifies one object.
355#[must_use]
356#[allow(clippy::too_many_lines)] // one invariant after another, in contract order
357pub fn object(cx: &Context<'_>, target: &Target) -> ObjectVerification {
358    let mut out = ObjectVerification {
359        object: target.object.clone(),
360        kind: target.kind.to_owned(),
361        task: target.task.clone(),
362        verdict: None,
363        outcome: VerifyOutcome::Ok,
364        reasons: Vec::new(),
365        signer: None,
366        statements: 0,
367        same_tree_as: None,
368    };
369    let Some(note) = notes::read(cx.repo, &target.object) else {
370        out.outcome = VerifyOutcome::Missing;
371        out.reasons.push(reason("no_note"));
372        out.same_tree_as = same_tree(cx.repo, &target.object);
373        return out;
374    };
375    let mut worst = VerifyOutcome::Ok;
376    let raise = |worst: &mut VerifyOutcome, o: VerifyOutcome| {
377        if o.rank() > worst.rank() {
378            *worst = o;
379        }
380    };
381    let (lines, unreadable) = dsse::decode(&note);
382    out.statements = lines.len();
383    if unreadable > 0 || lines.is_empty() {
384        raise(&mut worst, VerifyOutcome::Malformed);
385        out.reasons.push(reason("unreadable_line"));
386    }
387    let mut parsed: Vec<(Value, String, &Envelope)> = Vec::new();
388    for line in &lines {
389        match (line.statement(), line.body()) {
390            (Some(s), Some(body)) if s["_type"] == statement::STATEMENT => {
391                parsed.push((s, crate::integrity::sha256_hex(&body), line));
392            }
393            _ => {
394                raise(&mut worst, VerifyOutcome::Malformed);
395                out.reasons.push(reason("not_a_statement"));
396            }
397        }
398    }
399    // 1. Every statement is about this object.
400    for (s, ..) in &parsed {
401        let found = s["subject"][0]["digest"]["gitCommit"]
402            .as_str()
403            .unwrap_or_default();
404        if found != target.object {
405            raise(&mut worst, VerifyOutcome::Mismatch);
406            out.reasons.push(differs(
407                "subject",
408                "subject.digest.gitCommit",
409                &target.object,
410                found,
411            ));
412        }
413    }
414    // 2. Signatures, with the signer's own tool.
415    let mut unsigned = false;
416    // Each line's check is its own process; they run side by side.
417    let checks: Vec<Option<Checked>> = std::thread::scope(|scope| {
418        let handles: Vec<_> = parsed
419            .iter()
420            .map(|(_, _, line)| scope.spawn(move || check_line(line, cx.trust)))
421            .collect();
422        handles
423            .into_iter()
424            .map(|h| {
425                h.join().unwrap_or_else(|_| {
426                    Some(Checked::Bad("the signature check panicked".to_owned()))
427                })
428            })
429            .collect()
430    });
431    // A line a well-signed summary names by digest is covered by that
432    // signature (a keyless Sigstore tick signs only its summary: one login).
433    let covered: BTreeSet<String> = parsed
434        .iter()
435        .zip(&checks)
436        .filter(|((s, ..), c)| {
437            s["predicateType"] == statement::VSA && matches!(c, Some(Checked::Good(_)))
438        })
439        .flat_map(|((s, ..), _)| {
440            s["predicate"]["inputAttestations"]
441                .as_array()
442                .into_iter()
443                .flatten()
444                .filter_map(|i| i["digest"]["sha256"].as_str().map(str::to_owned))
445                .collect::<Vec<_>>()
446        })
447        .collect();
448    for ((_, digest, _), checked) in parsed.iter().zip(checks) {
449        match checked {
450            None if covered.contains(digest) => {}
451            None => unsigned = true,
452            Some(Checked::Good(v)) => {
453                out.signer.get_or_insert(v);
454            }
455            Some(Checked::Untrusted(v)) => {
456                raise(&mut worst, VerifyOutcome::UntrustedSigner);
457                out.reasons.push(reason("untrusted_signer"));
458                out.signer.get_or_insert(v);
459            }
460            Some(Checked::Bad(why)) => {
461                raise(&mut worst, VerifyOutcome::BadSignature);
462                let mut r = reason("signature");
463                r.found = Some(why);
464                out.reasons.push(r);
465            }
466            Some(Checked::ToolMissing(tool)) => {
467                raise(&mut worst, VerifyOutcome::ToolMissing);
468                let mut r = reason("tool_missing");
469                r.found = Some(format!(
470                    "{tool} cannot be run: install it to check this signature"
471                ));
472                out.reasons.push(r);
473            }
474        }
475    }
476    // 3–4. The task-verification agrees with the commit.
477    let tvs: Vec<&Value> = parsed
478        .iter()
479        .filter(|(s, ..)| s["predicateType"] == statement::TASK_VERIFICATION)
480        .map(|(s, ..)| &s["predicate"])
481        .collect();
482    let Some(tv) = tvs.first() else {
483        raise(&mut worst, VerifyOutcome::Mismatch);
484        out.reasons.push(reason("no_task_verification"));
485        out.outcome = worst;
486        return out;
487    };
488    let verdict = tv["verdict"].as_str().unwrap_or_default().to_owned();
489    out.verdict = Some(verdict.clone());
490    let tr = trailers(cx.repo, &target.object);
491    let want = |key: &str, found: &str, out: &mut ObjectVerification, worst: &mut VerifyOutcome| {
492        let expected = tr.get(key).map_or("", String::as_str);
493        if expected != found {
494            raise(worst, VerifyOutcome::Mismatch);
495            out.reasons.push(differs("trailer", key, expected, found));
496        }
497    };
498    let task_id = tv["task"]["id"].as_str().unwrap_or_default();
499    if target.kind == "task" {
500        want(
501            "Spec-Task",
502            &format!(
503                "{}/{task_id}",
504                tv["unit"]["feature"].as_str().unwrap_or_default()
505            ),
506            &mut out,
507            &mut worst,
508        );
509        want("Verdict", &verdict, &mut out, &mut worst);
510        // Why a regression-only tick is one: the trailer and the signed
511        // reason agree (a tick from before reasons has neither).
512        let regression = serde_json::from_value::<speccoder_core::regression::RegressionReason>(
513            tv["regression"].clone(),
514        )
515        .ok();
516        if regression.is_some() || tr.contains_key(speccoder_core::regression::TRAILER) {
517            want(
518                speccoder_core::regression::TRAILER,
519                &regression
520                    .as_ref()
521                    .map(speccoder_core::regression::trailer)
522                    .unwrap_or_default(),
523                &mut out,
524                &mut worst,
525            );
526        }
527        if let Some(run) = tv["run"].as_str() {
528            want("Run", run, &mut out, &mut worst);
529        }
530        if verdict == "attested" {
531            want(
532                "Accepted-By",
533                tv["decider"]["by"].as_str().unwrap_or_default(),
534                &mut out,
535                &mut worst,
536            );
537        }
538    } else {
539        want(
540            "Spec-Task",
541            &format!(
542                "{}/{task_id}",
543                tv["unit"]["feature"].as_str().unwrap_or_default()
544            ),
545            &mut out,
546            &mut worst,
547        );
548        want(
549            "Skipped-By",
550            tv["decider"]["by"].as_str().unwrap_or_default(),
551            &mut out,
552            &mut worst,
553        );
554        if verdict != "skipped" {
555            raise(&mut worst, VerifyOutcome::Mismatch);
556            out.reasons
557                .push(differs("verdict", "verdict", "skipped", &verdict));
558        }
559    }
560    let tree = Git::at(cx.repo)
561        .line(&["rev-parse", &format!("{}^{{tree}}", target.object)])
562        .unwrap_or_default();
563    if tv["tree"].as_str() != Some(tree.as_str()) {
564        raise(&mut worst, VerifyOutcome::Mismatch);
565        out.reasons.push(differs(
566            "tree",
567            "tree",
568            &tree,
569            tv["tree"].as_str().unwrap_or_default(),
570        ));
571    }
572    if let Some(path) = cx.tasks_path
573        && let Some(at) = line_hash_at(cx, &target.object, path, task_id)
574    {
575        let claimed = tv["task"]["line_hash"].as_str().unwrap_or_default();
576        if claimed != at {
577            raise(&mut worst, VerifyOutcome::Mismatch);
578            out.reasons
579                .push(differs("line_hash", "task.line_hash", &at, claimed));
580        }
581    }
582    if let Some(section) = tv["spec"]["section"].as_str() {
583        let claimed = tv["spec"]["section_hash"].as_str().unwrap_or_default();
584        match section_hash_at(cx, &target.object, section, task_id) {
585            Ok(now) if claimed == now => {}
586            Ok(now) => {
587                raise(&mut worst, VerifyOutcome::Mismatch);
588                out.reasons
589                    .push(differs("section_hash", "spec.section_hash", &now, claimed));
590            }
591            // A requirement that cannot be read at the commit is not one
592            // the claim agrees with: never a check skipped.
593            Err(why) => {
594                raise(&mut worst, VerifyOutcome::Mismatch);
595                let mut r = reason("section_hash");
596                r.field = Some("spec.section_hash".to_owned());
597                r.found = Some(why);
598                out.reasons.push(r);
599            }
600        }
601    }
602    // 5–6. The summary names exactly the other lines; decisions have none.
603    let vsas: Vec<&(Value, String, &Envelope)> = parsed
604        .iter()
605        .filter(|(s, ..)| s["predicateType"] == statement::VSA)
606        .collect();
607    let test_results = parsed
608        .iter()
609        .filter(|(s, ..)| s["predicateType"] == statement::TEST_RESULT)
610        .count();
611    if matches!(verdict.as_str(), "verified" | "regression_only") {
612        match vsas.as_slice() {
613            [(v, own, _)] => {
614                let named: BTreeSet<String> = v["predicate"]["inputAttestations"]
615                    .as_array()
616                    .into_iter()
617                    .flatten()
618                    .filter_map(|i| i["digest"]["sha256"].as_str().map(str::to_owned))
619                    .collect();
620                let present: BTreeSet<String> = parsed
621                    .iter()
622                    .map(|(_, d, _)| d.clone())
623                    .filter(|d| d != own)
624                    .collect();
625                if named != present {
626                    raise(&mut worst, VerifyOutcome::Mismatch);
627                    out.reasons.push(differs(
628                        "bundle",
629                        "inputAttestations",
630                        &format!("{} lines", named.len()),
631                        &format!("{} lines", present.len()),
632                    ));
633                }
634                let level = v["predicate"]["verifiedLevels"][0]
635                    .as_str()
636                    .unwrap_or_default();
637                let expected = if verdict == "verified" {
638                    "SPECCODER_VERIFIED"
639                } else {
640                    "SPECCODER_REGRESSION_ONLY"
641                };
642                if level != expected {
643                    raise(&mut worst, VerifyOutcome::Mismatch);
644                    out.reasons
645                        .push(differs("level", "verifiedLevels", expected, level));
646                }
647                let summary_policy = v["predicate"]["policy"]["digest"]["sha256"]
648                    .as_str()
649                    .unwrap_or_default();
650                let claimed = tv["policy_sha256"].as_str().unwrap_or_default();
651                if summary_policy != claimed {
652                    raise(&mut worst, VerifyOutcome::Mismatch);
653                    out.reasons
654                        .push(differs("policy", "policy.digest", claimed, summary_policy));
655                }
656                // Recomputed as the version that made the tick did.
657                let made_by = v["predicate"]["verifier"]["version"]["speccoder"]
658                    .as_str()
659                    .unwrap_or(env!("CARGO_PKG_VERSION"));
660                if let Some(at) = policy_at(cx, &target.object, made_by)
661                    && at != claimed
662                {
663                    raise(&mut worst, VerifyOutcome::Mismatch);
664                    out.reasons
665                        .push(differs("policy", "policy_sha256", &at, claimed));
666                }
667            }
668            _ => {
669                raise(&mut worst, VerifyOutcome::Mismatch);
670                out.reasons
671                    .push(differs("bundle", "vsa", "1", &vsas.len().to_string()));
672            }
673        }
674    } else if !vsas.is_empty() || test_results > 0 {
675        raise(&mut worst, VerifyOutcome::Mismatch);
676        out.reasons.push(reason("decision_with_verification"));
677    }
678    if worst == VerifyOutcome::Ok && unsigned {
679        worst = VerifyOutcome::Unsigned;
680    }
681    out.outcome = worst;
682    out
683}
684
685/// The judge-config hash recomputed from `speccoder.toml` at the parent, as
686/// speccoder `version` (the tick's) computed it.
687fn policy_at(cx: &Context<'_>, object: &str, version: &str) -> Option<String> {
688    let text = Git::at(cx.repo)
689        .run(&["show", &format!("{object}^:{}", cx.config_path)])
690        .ok()
691        .map(|b| String::from_utf8_lossy(&b).into_owned())?;
692    super::policy::judge_config_sha256_as(&text, version).ok()
693}
694
695/// The requirement's text hash at the commit, through the format adapter
696/// over the commit's tree: what the spec said when the task was ticked.
697fn section_hash_at(
698    cx: &Context<'_>,
699    object: &str,
700    _section: &str,
701    task: &str,
702) -> Result<String, String> {
703    // Only what a spec format reads: the top level's files and dot
704    // directories, the directories holding a format's intent, and the tree
705    // holding the unit — never the source tree, whose size would make
706    // every check slow. Written from git objects: nothing but git runs.
707    let tops = match cx.tasks_path {
708        Some(p) => {
709            let mut tops = super::tree::intent_tops(cx.repo, object)?;
710            if let Some(unit_top) = p.split('/').next()
711                && !tops.iter().any(|t| t == unit_top)
712            {
713                tops.push(unit_top.to_owned());
714            }
715            Some(tops)
716        }
717        None => None,
718    };
719    let dir = super::tree::materialize(cx.repo, object, tops.as_deref())?;
720    let format = crate::format::at(dir.path());
721    let snap = format
722        .inspect(dir.path(), Some(cx.feature))
723        .ok_or_else(|| format!("the commit holds no unit {}", cx.feature))?;
724    let bundle = format
725        .bundle(&snap, task)
726        .map_err(|e| format!("the commit's unit holds no task {task}: {e:?}"))?;
727    Ok(crate::integrity::sha256_hex(bundle.story.as_bytes()))
728}
729
730/// Another noted commit with the same tree (a rebase lost the note).
731fn same_tree(repo: &Path, object: &str) -> Option<String> {
732    let git = Git::at(repo);
733    let tree = git
734        .line(&["rev-parse", &format!("{object}^{{tree}}")])
735        .ok()?;
736    notes::noted(repo).into_iter().find(|c| {
737        c != object
738            && git
739                .line(&["rev-parse", &format!("{c}^{{tree}}")])
740                .is_ok_and(|t| t == tree)
741    })
742}
743
744/// The task commits to verify: `rev` or the range's commits carrying
745/// `Spec-Task`, or every task commit on `speccoder/<feature>`.
746pub fn targets(repo: &Path, feature: &str, rev: Option<&str>) -> Result<Vec<Target>, String> {
747    let git = Git::at(repo);
748    let spec = match rev {
749        Some(r) if r.contains("..") => r.to_owned(),
750        Some(r) => {
751            let commit = git
752                .line(&["rev-parse", "--verify", &format!("{r}^{{commit}}")])
753                .map_err(|e| e.0)?;
754            let task = trailers(repo, &commit)
755                .get("Spec-Task")
756                .and_then(|t| t.rsplit('/').next().map(str::to_owned))
757                .unwrap_or_default();
758            return Ok(vec![Target {
759                object: commit,
760                kind: "task",
761                task,
762            }]);
763        }
764        None => format!("speccoder/{feature}"),
765    };
766    let log = git
767        .run(&[
768            "log",
769            "--format=%H%x1f%(trailers:key=Spec-Task,valueonly)%x1f%(trailers:key=Verdict,valueonly)%x1e",
770            &spec,
771        ])
772        .map_err(|e| e.0)?;
773    let mut out = Vec::new();
774    for record in String::from_utf8_lossy(&log).split('\u{1e}') {
775        let f: Vec<&str> = record.trim().split('\u{1f}').collect();
776        let [commit, task, verdict] = f[..] else {
777            continue;
778        };
779        let (task, verdict) = (task.trim(), verdict.trim());
780        if let Some(id) = task.strip_prefix(&format!("{feature}/"))
781            && !verdict.is_empty()
782        {
783            out.push(Target {
784                object: commit.to_owned(),
785                kind: "task",
786                task: id.to_owned(),
787            });
788        }
789    }
790    out.reverse();
791    // The feature's skips, by default: a skip is a decision on the record too.
792    if rev.is_none() {
793        let refs = git
794            .run(&[
795                "for-each-ref",
796                "--format=%(objectname) %(refname)",
797                &format!("{}/{feature}/", notes::SKIPPED),
798            ])
799            .map(|b| String::from_utf8_lossy(&b).into_owned())
800            .unwrap_or_default();
801        for line in refs.lines() {
802            if let Some((object, name)) = line.split_once(' ') {
803                out.push(Target {
804                    object: object.to_owned(),
805                    kind: "skip",
806                    task: name.rsplit('/').next().unwrap_or_default().to_owned(),
807                });
808            }
809        }
810    }
811    Ok(out)
812}
813
814/// `speccoder attest verify` over `targets`.
815#[must_use]
816pub fn report(cx: &Context<'_>, targets: &[Target], require_signed: bool) -> CommandResult {
817    // Objects are independent; a few at a time, in the targets' order.
818    let width = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
819    let objects: Vec<ObjectVerification> = targets
820        .chunks(width.max(1))
821        .flat_map(|chunk| {
822            std::thread::scope(|scope| {
823                let handles: Vec<_> = chunk
824                    .iter()
825                    .map(|t| scope.spawn(move || object(cx, t)))
826                    .collect();
827                handles
828                    .into_iter()
829                    .zip(chunk)
830                    .map(|(h, t)| {
831                        h.join().unwrap_or_else(|_| ObjectVerification {
832                            object: t.object.clone(),
833                            kind: t.kind.to_owned(),
834                            task: t.task.clone(),
835                            verdict: None,
836                            outcome: VerifyOutcome::Malformed,
837                            reasons: vec![reason("check_panicked")],
838                            signer: None,
839                            statements: 0,
840                            same_tree_as: None,
841                        })
842                    })
843                    .collect::<Vec<_>>()
844            })
845        })
846        .collect();
847    // In the order the summary reads: ok first, then the worst first.
848    const KINDS: [&str; 8] = [
849        "ok",
850        "missing",
851        "malformed",
852        "bad_signature",
853        "mismatch",
854        "tool_missing",
855        "untrusted_signer",
856        "unsigned",
857    ];
858    let mut totals: BTreeMap<String, usize> = KINDS.iter().map(|k| ((*k).to_owned(), 0)).collect();
859    for o in &objects {
860        *totals.entry(o.outcome.word().to_owned()).or_default() += 1;
861    }
862    let failed = objects.iter().any(|o| match o.outcome {
863        VerifyOutcome::Ok => false,
864        VerifyOutcome::Unsigned => require_signed,
865        _ => true,
866    });
867    let summary: Vec<String> = KINDS
868        .iter()
869        .filter_map(|k| {
870            totals
871                .get(*k)
872                .filter(|n| **n > 0)
873                .map(|n| format!("{n} {k}"))
874        })
875        .collect();
876    let message = format!(
877        "{} task object(s): {}",
878        objects.len(),
879        if summary.is_empty() {
880            "none".to_owned()
881        } else {
882            summary.join(", ")
883        }
884    );
885    let status = if failed {
886        CommandStatus::AttestationFailed
887    } else {
888        CommandStatus::Ok
889    };
890    CommandResult::new("attest", status, &message).with_data(CommandData::Attest(Box::new(
891        AttestReport {
892            feature: cx.feature.to_owned(),
893            trust: serde_json::json!({
894                "ssh_allowed_signers": cx.trust.allowed_signers.as_ref().map(|p| p.display().to_string()),
895                "gpg": true,
896                "sigstore": cx.trust.identity,
897            }),
898            objects,
899            totals,
900        },
901    )))
902}
903
904/// `speccoder attest verify` for the project at `path`.
905#[must_use]
906pub fn verify_at(
907    path: &Path,
908    feature: Option<&str>,
909    rev: Option<&str>,
910    trust: &Trust,
911    require_signed: bool,
912) -> CommandResult {
913    let inspected = crate::inspect(path, feature);
914    let Some(CommandData::Inspect(snap)) = inspected.data() else {
915        return CommandResult::carried("attest", &inspected);
916    };
917    let Some(f) = snap.feature.as_ref() else {
918        return CommandResult::new(
919            "attest",
920            CommandStatus::SelectionNeeded,
921            inspected.message(),
922        );
923    };
924    let root = PathBuf::from(&snap.root);
925    let git = Git::at(&root);
926    let top = git
927        .toplevel()
928        .ok()
929        .flatten()
930        .unwrap_or_else(|| root.clone());
931    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
932    let rel = canonical(&root)
933        .strip_prefix(canonical(&top))
934        .map(Path::to_path_buf)
935        .unwrap_or_default();
936    let in_repo = |p: &str| rel.join(p).to_string_lossy().replace('\\', "/");
937    let tasks_path = f.tasks.as_ref().map(|t| in_repo(&t.path));
938    let config_path = crate::verify::find_config(&root)
939        .and_then(|d| {
940            canonical(&d)
941                .strip_prefix(canonical(&top))
942                .ok()
943                .map(Path::to_path_buf)
944        })
945        .map_or_else(
946            || crate::verify::config::FILE.to_owned(),
947            |d| {
948                d.join(crate::verify::config::FILE)
949                    .to_string_lossy()
950                    .replace('\\', "/")
951            },
952        );
953    let mut trust = trust.clone();
954    if trust.allowed_signers.is_none() {
955        trust.allowed_signers = git
956            .line(&["config", "gpg.ssh.allowedSignersFile"])
957            .ok()
958            .filter(|p| !p.is_empty())
959            .map(|p| match (p.strip_prefix("~/"), std::env::var_os("HOME")) {
960                (Some(rest), Some(home)) => PathBuf::from(home).join(rest),
961                _ => PathBuf::from(p),
962            });
963    }
964    let targets = match targets(&top, &f.feature.name, rev) {
965        Ok(t) => t,
966        Err(e) => return CommandResult::new("attest", CommandStatus::GitPrecondition, &e),
967    };
968    if targets.is_empty() {
969        return CommandResult::new(
970            "attest",
971            CommandStatus::NotFound,
972            &format!("no ticked task of {} to verify", f.feature.name),
973        );
974    }
975    let cx = Context {
976        repo: &top,
977        feature: &f.feature.name,
978        tasks_path: tasks_path.as_deref(),
979        config_path: &config_path,
980        trust: &trust,
981        format: None,
982    };
983    report(&cx, &targets, require_signed)
984}
985
986#[cfg(test)]
987mod tests {
988    use super::*;
989
990    const FPR: &str = "0123456789ABCDEF0123456789ABCDEF01234567";
991
992    fn status(uid: &str, trust: &str) -> String {
993        format!(
994            "[GNUPG:] NEWSIG\n[GNUPG:] KEY_CONSIDERED {FPR} 0\n[GNUPG:] SIG_ID abc 2026-10-26 1793000000\n\
995             [GNUPG:] GOODSIG 89ABCDEF01234567 {uid}\n\
996             [GNUPG:] VALIDSIG {FPR} 2026-10-26 1793000000 0 4 0 22 10 00 {FPR}\n\
997             [GNUPG:] {trust} 0 pgp\n"
998        )
999    }
1000
1001    #[test]
1002    fn a_fully_trusted_good_signature_is_good() {
1003        assert_eq!(
1004            gpg_status(&status("Ada <ada@example.com>", "TRUST_ULTIMATE"), true),
1005            Gpg::Good {
1006                fingerprint: FPR.to_owned(),
1007                uid: Some("Ada <ada@example.com>".to_owned()),
1008            }
1009        );
1010        assert!(matches!(
1011            gpg_status(&status("Ada <ada@example.com>", "TRUST_FULLY"), true),
1012            Gpg::Good { .. }
1013        ));
1014    }
1015
1016    #[test]
1017    fn trust_words_in_the_signers_user_id_are_not_trust() {
1018        // The user ID is the signer's own text: a key nobody trusts that
1019        // calls itself TRUST_ULTIMATE is still a key nobody trusts.
1020        for uid in [
1021            "Eve TRUST_ULTIMATE <eve@example.com>",
1022            "Eve [GNUPG:] TRUST_FULLY 0 pgp",
1023            "TRUST_ULTIMATE",
1024        ] {
1025            assert!(
1026                matches!(
1027                    gpg_status(&status(uid, "TRUST_UNDEFINED"), true),
1028                    Gpg::Untrusted { .. }
1029                ),
1030                "{uid}"
1031            );
1032        }
1033        // A line the uid forges is a second trust line: not one good signature.
1034        let forged = status("Eve\n[GNUPG:] TRUST_ULTIMATE 0 pgp", "TRUST_UNDEFINED");
1035        assert!(matches!(gpg_status(&forged, true), Gpg::Untrusted { .. }));
1036    }
1037
1038    #[test]
1039    fn only_one_good_signature_by_the_key_gpg_validated_is_good() {
1040        let s = status("Ada", "TRUST_ULTIMATE");
1041        // gpg failed, a bad signature, an expired key, no VALIDSIG, a
1042        // GOODSIG of another key than VALIDSIG's.
1043        assert!(matches!(gpg_status(&s, false), Gpg::Untrusted { .. }));
1044        assert_eq!(
1045            gpg_status(&format!("{s}[GNUPG:] BADSIG 89ABCDEF01234567 Ada\n"), true),
1046            Gpg::Bad
1047        );
1048        assert!(matches!(
1049            gpg_status(
1050                &format!("{s}[GNUPG:] EXPKEYSIG 89ABCDEF01234567 Ada\n"),
1051                true
1052            ),
1053            Gpg::Untrusted { .. }
1054        ));
1055        let no_valid: String = s
1056            .lines()
1057            .filter(|l| !l.contains("VALIDSIG"))
1058            .map(|l| format!("{l}\n"))
1059            .collect();
1060        assert!(matches!(gpg_status(&no_valid, true), Gpg::Untrusted { .. }));
1061        let other = s.replace("GOODSIG 89ABCDEF01234567", "GOODSIG FFFFFFFFFFFFFFFF");
1062        assert!(matches!(gpg_status(&other, true), Gpg::Untrusted { .. }));
1063        // Not at a line's start: not a status line.
1064        let indented = s.replace("[GNUPG:] TRUST_ULTIMATE", "x [GNUPG:] TRUST_ULTIMATE");
1065        assert!(matches!(gpg_status(&indented, true), Gpg::Untrusted { .. }));
1066    }
1067}