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}
69
70fn run(mut cmd: Command, input: Option<&[u8]>) -> Option<(bool, String, String)> {
71    let mut child = cmd
72        .stdin(if input.is_some() {
73            Stdio::piped()
74        } else {
75            Stdio::null()
76        })
77        .stdout(Stdio::piped())
78        .stderr(Stdio::piped())
79        .spawn()
80        .ok()?;
81    if let (Some(data), Some(mut stdin)) = (input, child.stdin.take()) {
82        let _ = stdin.write_all(data);
83    }
84    let out = child.wait_with_output().ok()?;
85    Some((
86        out.status.success(),
87        String::from_utf8_lossy(&out.stdout).into_owned(),
88        String::from_utf8_lossy(&out.stderr).into_owned(),
89    ))
90}
91
92fn check_ssh(armored: &[u8], data: &[u8], keyid: &str, allowed: Option<&Path>) -> Checked {
93    let Ok(dir) = tempfile::tempdir() else {
94        return Checked::Bad("no temporary directory".to_owned());
95    };
96    let sig = dir.path().join("line.sig");
97    if std::fs::write(&sig, armored).is_err() {
98        return Checked::Bad("cannot write the signature".to_owned());
99    }
100    let view = |principal: Option<String>| SignerView {
101        kind: "ssh".to_owned(),
102        keyid: keyid.to_owned(),
103        principal,
104    };
105    if let Some(allowed) = allowed {
106        let mut find = Command::new("ssh-keygen");
107        find.args(["-Y", "find-principals", "-s"])
108            .arg(&sig)
109            .arg("-f")
110            .arg(allowed);
111        if let Some((true, out, _)) = run(find, None)
112            && let Some(principal) = out.lines().next().map(str::to_owned)
113        {
114            let mut verify = Command::new("ssh-keygen");
115            verify
116                .args(["-Y", "verify", "-f"])
117                .arg(allowed)
118                .args(["-I", &principal, "-n", super::sign::NAMESPACE, "-s"])
119                .arg(&sig);
120            return match run(verify, Some(data)) {
121                Some((true, ..)) => Checked::Good(view(Some(principal))),
122                Some((false, _, err)) => Checked::Bad(err.trim().to_owned()),
123                None => Checked::Bad("ssh-keygen did not run".to_owned()),
124            };
125        }
126    }
127    // Nobody the verifier trusts: is it at least a valid signature?
128    let mut check = Command::new("ssh-keygen");
129    check
130        .args(["-Y", "check-novalidate", "-n", super::sign::NAMESPACE, "-s"])
131        .arg(&sig);
132    match run(check, Some(data)) {
133        Some((true, ..)) => Checked::Untrusted(view(None)),
134        Some((false, _, err)) => Checked::Bad(err.trim().to_owned()),
135        None => Checked::Bad("ssh-keygen did not run".to_owned()),
136    }
137}
138
139fn check_openpgp(armored: &[u8], data: &[u8], keyid: &str) -> Checked {
140    let Ok(dir) = tempfile::tempdir() else {
141        return Checked::Bad("no temporary directory".to_owned());
142    };
143    let sig = dir.path().join("line.asc");
144    let body = dir.path().join("line.pae");
145    if std::fs::write(&sig, armored).is_err() || std::fs::write(&body, data).is_err() {
146        return Checked::Bad("cannot write the signature".to_owned());
147    }
148    let mut cmd = Command::new("gpg");
149    cmd.args(["--batch", "--status-fd", "1", "--verify"])
150        .arg(&sig)
151        .arg(&body);
152    let Some((_, status, _)) = run(cmd, None) else {
153        return Checked::Bad("gpg did not run".to_owned());
154    };
155    let view = SignerView {
156        kind: "openpgp".to_owned(),
157        keyid: keyid.to_owned(),
158        principal: status
159            .lines()
160            .find_map(|l| l.strip_prefix("[GNUPG:] GOODSIG "))
161            .and_then(|l| l.split_once(' ').map(|(_, uid)| uid.to_owned())),
162    };
163    if status.contains("[GNUPG:] BADSIG") {
164        Checked::Bad("gpg: BAD signature".to_owned())
165    } else if status.contains("[GNUPG:] VALIDSIG") {
166        if status.contains("TRUST_ULTIMATE") || status.contains("TRUST_FULLY") {
167            Checked::Good(view)
168        } else {
169            Checked::Untrusted(view)
170        }
171    } else {
172        // ERRSIG / NO_PUBKEY: the verifier does not hold the key.
173        Checked::Untrusted(view)
174    }
175}
176
177fn check_sigstore(line: &Envelope, data: &[u8], trust: &Trust) -> Checked {
178    let view = SignerView {
179        kind: "sigstore".to_owned(),
180        keyid: "sigstore".to_owned(),
181        principal: trust.identity.clone(),
182    };
183    let (Some(material), Some(identity), Some(issuer)) =
184        (&line.sigstore, &trust.identity, &trust.issuer)
185    else {
186        return Checked::Untrusted(view);
187    };
188    let Ok(dir) = tempfile::tempdir() else {
189        return Checked::Bad("no temporary directory".to_owned());
190    };
191    let blob = dir.path().join("line.pae");
192    let bundle = dir.path().join("line.sigstore.json");
193    if std::fs::write(&blob, data).is_err()
194        || std::fs::write(&bundle, material.to_string()).is_err()
195    {
196        return Checked::Bad("cannot write the bundle".to_owned());
197    }
198    let mut cmd = Command::new("cosign");
199    cmd.args(["verify-blob", "--bundle"])
200        .arg(&bundle)
201        .args([
202            "--certificate-identity",
203            identity,
204            "--certificate-oidc-issuer",
205            issuer,
206        ])
207        .arg(&blob);
208    match run(cmd, None) {
209        Some((true, ..)) => Checked::Good(view),
210        Some((false, _, err)) => Checked::Bad(err.trim().to_owned()),
211        None => Checked::Untrusted(view),
212    }
213}
214
215fn check_line(line: &Envelope, trust: &Trust) -> Option<Checked> {
216    let sig = line.signatures.first()?;
217    let Some(data) = line.signed_bytes() else {
218        return Some(Checked::Bad("the payload is not base64".to_owned()));
219    };
220    if line.sigstore.is_some() {
221        return Some(check_sigstore(line, &data, trust));
222    }
223    let Ok(armored) = STANDARD.decode(&sig.sig) else {
224        return Some(Checked::Bad("the signature is not base64".to_owned()));
225    };
226    let text = String::from_utf8_lossy(&armored);
227    Some(if text.contains("BEGIN SSH SIGNATURE") {
228        check_ssh(
229            &armored,
230            &data,
231            &sig.keyid,
232            trust.allowed_signers.as_deref(),
233        )
234    } else if text.contains("BEGIN PGP SIGNATURE") {
235        check_openpgp(&armored, &data, &sig.keyid)
236    } else {
237        Checked::Bad("an unknown signature format".to_owned())
238    })
239}
240
241/// The commit's trailers by key.
242fn trailers(repo: &Path, object: &str) -> BTreeMap<String, String> {
243    let text = Git::at(repo)
244        .run(&["log", "-1", "--format=%(trailers:only,unfold)", object])
245        .map(|b| String::from_utf8_lossy(&b).into_owned())
246        .unwrap_or_default();
247    text.lines()
248        .filter_map(|l| l.split_once(": "))
249        .map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned()))
250        .collect()
251}
252
253/// The task line's hash in the tasks file at the commit, with its box read
254/// as open (the hash a statement names was taken before the tick).
255fn line_hash_at(repo: &Path, object: &str, tasks_path: &str, task: &str) -> Option<String> {
256    let text = Git::at(repo)
257        .run(&["show", &format!("{object}:{tasks_path}")])
258        .ok()
259        .map(|b| String::from_utf8_lossy(&b).into_owned())?;
260    let format = crate::format::at(repo);
261    let (parsed, _) = format.parse_tasks(tasks_path, &text);
262    let t = parsed.tasks.iter().find(|t| t.id == task)?;
263    let line = text
264        .lines()
265        .nth(usize::try_from(t.line).ok()?.checked_sub(1)?)?;
266    Some(crate::integrity::sha256_hex(&crate::in_place::untick(
267        line.as_bytes(),
268    )))
269}
270
271/// Everything that decides an object's outcome, given the trust input.
272pub struct Context<'a> {
273    /// The repository.
274    pub repo: &'a Path,
275    /// The feature.
276    pub feature: &'a str,
277    /// The tasks file's path in the repository, when tracked.
278    pub tasks_path: Option<&'a str>,
279    /// `speccoder.toml`'s path in the repository.
280    pub config_path: &'a str,
281    /// The verifier's trust.
282    pub trust: &'a Trust,
283}
284
285/// Verifies one object.
286#[must_use]
287#[allow(clippy::too_many_lines)] // one invariant after another, in contract order
288pub fn object(cx: &Context<'_>, target: &Target) -> ObjectVerification {
289    let mut out = ObjectVerification {
290        object: target.object.clone(),
291        kind: target.kind.to_owned(),
292        task: target.task.clone(),
293        verdict: None,
294        outcome: VerifyOutcome::Ok,
295        reasons: Vec::new(),
296        signer: None,
297        statements: 0,
298        same_tree_as: None,
299    };
300    let Some(note) = notes::read(cx.repo, &target.object) else {
301        out.outcome = VerifyOutcome::Missing;
302        out.reasons.push(reason("no_note"));
303        out.same_tree_as = same_tree(cx.repo, &target.object);
304        return out;
305    };
306    let mut worst = VerifyOutcome::Ok;
307    let raise = |worst: &mut VerifyOutcome, o: VerifyOutcome| {
308        if o.rank() > worst.rank() {
309            *worst = o;
310        }
311    };
312    let (lines, unreadable) = dsse::decode(&note);
313    out.statements = lines.len();
314    if unreadable > 0 || lines.is_empty() {
315        raise(&mut worst, VerifyOutcome::Malformed);
316        out.reasons.push(reason("unreadable_line"));
317    }
318    let mut parsed: Vec<(Value, String, &Envelope)> = Vec::new();
319    for line in &lines {
320        match (line.statement(), line.body()) {
321            (Some(s), Some(body)) if s["_type"] == statement::STATEMENT => {
322                parsed.push((s, crate::integrity::sha256_hex(&body), line));
323            }
324            _ => {
325                raise(&mut worst, VerifyOutcome::Malformed);
326                out.reasons.push(reason("not_a_statement"));
327            }
328        }
329    }
330    // 1. Every statement is about this object.
331    for (s, ..) in &parsed {
332        let found = s["subject"][0]["digest"]["gitCommit"]
333            .as_str()
334            .unwrap_or_default();
335        if found != target.object {
336            raise(&mut worst, VerifyOutcome::Mismatch);
337            out.reasons.push(differs(
338                "subject",
339                "subject.digest.gitCommit",
340                &target.object,
341                found,
342            ));
343        }
344    }
345    // 2. Signatures, with the signer's own tool.
346    let mut unsigned = false;
347    // Each line's check is its own process; they run side by side.
348    let checks: Vec<Option<Checked>> = std::thread::scope(|scope| {
349        let handles: Vec<_> = parsed
350            .iter()
351            .map(|(_, _, line)| scope.spawn(move || check_line(line, cx.trust)))
352            .collect();
353        handles
354            .into_iter()
355            .map(|h| {
356                h.join().unwrap_or_else(|_| {
357                    Some(Checked::Bad("the signature check panicked".to_owned()))
358                })
359            })
360            .collect()
361    });
362    // A line a well-signed summary names by digest is covered by that
363    // signature (a keyless Sigstore tick signs only its summary: one login).
364    let covered: BTreeSet<String> = parsed
365        .iter()
366        .zip(&checks)
367        .filter(|((s, ..), c)| {
368            s["predicateType"] == statement::VSA && matches!(c, Some(Checked::Good(_)))
369        })
370        .flat_map(|((s, ..), _)| {
371            s["predicate"]["inputAttestations"]
372                .as_array()
373                .into_iter()
374                .flatten()
375                .filter_map(|i| i["digest"]["sha256"].as_str().map(str::to_owned))
376                .collect::<Vec<_>>()
377        })
378        .collect();
379    for ((_, digest, _), checked) in parsed.iter().zip(checks) {
380        match checked {
381            None if covered.contains(digest) => {}
382            None => unsigned = true,
383            Some(Checked::Good(v)) => {
384                out.signer.get_or_insert(v);
385            }
386            Some(Checked::Untrusted(v)) => {
387                raise(&mut worst, VerifyOutcome::UntrustedSigner);
388                out.reasons.push(reason("untrusted_signer"));
389                out.signer.get_or_insert(v);
390            }
391            Some(Checked::Bad(why)) => {
392                raise(&mut worst, VerifyOutcome::BadSignature);
393                let mut r = reason("signature");
394                r.found = Some(why);
395                out.reasons.push(r);
396            }
397        }
398    }
399    // 3–4. The task-verification agrees with the commit.
400    let tvs: Vec<&Value> = parsed
401        .iter()
402        .filter(|(s, ..)| s["predicateType"] == statement::TASK_VERIFICATION)
403        .map(|(s, ..)| &s["predicate"])
404        .collect();
405    let Some(tv) = tvs.first() else {
406        raise(&mut worst, VerifyOutcome::Mismatch);
407        out.reasons.push(reason("no_task_verification"));
408        out.outcome = worst;
409        return out;
410    };
411    let verdict = tv["verdict"].as_str().unwrap_or_default().to_owned();
412    out.verdict = Some(verdict.clone());
413    let tr = trailers(cx.repo, &target.object);
414    let want = |key: &str, found: &str, out: &mut ObjectVerification, worst: &mut VerifyOutcome| {
415        let expected = tr.get(key).map_or("", String::as_str);
416        if expected != found {
417            raise(worst, VerifyOutcome::Mismatch);
418            out.reasons.push(differs("trailer", key, expected, found));
419        }
420    };
421    let task_id = tv["task"]["id"].as_str().unwrap_or_default();
422    if target.kind == "task" {
423        want(
424            "Spec-Task",
425            &format!(
426                "{}/{task_id}",
427                tv["unit"]["feature"].as_str().unwrap_or_default()
428            ),
429            &mut out,
430            &mut worst,
431        );
432        want("Verdict", &verdict, &mut out, &mut worst);
433        if let Some(run) = tv["run"].as_str() {
434            want("Run", run, &mut out, &mut worst);
435        }
436        if verdict == "attested" {
437            want(
438                "Accepted-By",
439                tv["decider"]["by"].as_str().unwrap_or_default(),
440                &mut out,
441                &mut worst,
442            );
443        }
444    } else {
445        want(
446            "Spec-Task",
447            &format!(
448                "{}/{task_id}",
449                tv["unit"]["feature"].as_str().unwrap_or_default()
450            ),
451            &mut out,
452            &mut worst,
453        );
454        want(
455            "Skipped-By",
456            tv["decider"]["by"].as_str().unwrap_or_default(),
457            &mut out,
458            &mut worst,
459        );
460        if verdict != "skipped" {
461            raise(&mut worst, VerifyOutcome::Mismatch);
462            out.reasons
463                .push(differs("verdict", "verdict", "skipped", &verdict));
464        }
465    }
466    let tree = Git::at(cx.repo)
467        .line(&["rev-parse", &format!("{}^{{tree}}", target.object)])
468        .unwrap_or_default();
469    if tv["tree"].as_str() != Some(tree.as_str()) {
470        raise(&mut worst, VerifyOutcome::Mismatch);
471        out.reasons.push(differs(
472            "tree",
473            "tree",
474            &tree,
475            tv["tree"].as_str().unwrap_or_default(),
476        ));
477    }
478    if let Some(path) = cx.tasks_path
479        && let Some(at) = line_hash_at(cx.repo, &target.object, path, task_id)
480    {
481        let claimed = tv["task"]["line_hash"].as_str().unwrap_or_default();
482        if claimed != at {
483            raise(&mut worst, VerifyOutcome::Mismatch);
484            out.reasons
485                .push(differs("line_hash", "task.line_hash", &at, claimed));
486        }
487    }
488    if let Some(section) = tv["spec"]["section"].as_str()
489        && let Some(now) = section_hash_at(cx, &target.object, section, task_id)
490    {
491        let claimed = tv["spec"]["section_hash"].as_str().unwrap_or_default();
492        if claimed != now {
493            raise(&mut worst, VerifyOutcome::Mismatch);
494            out.reasons
495                .push(differs("section_hash", "spec.section_hash", &now, claimed));
496        }
497    }
498    // 5–6. The summary names exactly the other lines; decisions have none.
499    let vsas: Vec<&(Value, String, &Envelope)> = parsed
500        .iter()
501        .filter(|(s, ..)| s["predicateType"] == statement::VSA)
502        .collect();
503    let test_results = parsed
504        .iter()
505        .filter(|(s, ..)| s["predicateType"] == statement::TEST_RESULT)
506        .count();
507    if matches!(verdict.as_str(), "verified" | "regression_only") {
508        match vsas.as_slice() {
509            [(v, own, _)] => {
510                let named: BTreeSet<String> = v["predicate"]["inputAttestations"]
511                    .as_array()
512                    .into_iter()
513                    .flatten()
514                    .filter_map(|i| i["digest"]["sha256"].as_str().map(str::to_owned))
515                    .collect();
516                let present: BTreeSet<String> = parsed
517                    .iter()
518                    .map(|(_, d, _)| d.clone())
519                    .filter(|d| d != own)
520                    .collect();
521                if named != present {
522                    raise(&mut worst, VerifyOutcome::Mismatch);
523                    out.reasons.push(differs(
524                        "bundle",
525                        "inputAttestations",
526                        &format!("{} lines", named.len()),
527                        &format!("{} lines", present.len()),
528                    ));
529                }
530                let level = v["predicate"]["verifiedLevels"][0]
531                    .as_str()
532                    .unwrap_or_default();
533                let expected = if verdict == "verified" {
534                    "SPECCODER_VERIFIED"
535                } else {
536                    "SPECCODER_REGRESSION_ONLY"
537                };
538                if level != expected {
539                    raise(&mut worst, VerifyOutcome::Mismatch);
540                    out.reasons
541                        .push(differs("level", "verifiedLevels", expected, level));
542                }
543                let summary_policy = v["predicate"]["policy"]["digest"]["sha256"]
544                    .as_str()
545                    .unwrap_or_default();
546                let claimed = tv["policy_sha256"].as_str().unwrap_or_default();
547                if summary_policy != claimed {
548                    raise(&mut worst, VerifyOutcome::Mismatch);
549                    out.reasons
550                        .push(differs("policy", "policy.digest", claimed, summary_policy));
551                }
552                if let Some(at) = policy_at(cx, &target.object)
553                    && at != claimed
554                {
555                    raise(&mut worst, VerifyOutcome::Mismatch);
556                    out.reasons
557                        .push(differs("policy", "policy_sha256", &at, claimed));
558                }
559            }
560            _ => {
561                raise(&mut worst, VerifyOutcome::Mismatch);
562                out.reasons
563                    .push(differs("bundle", "vsa", "1", &vsas.len().to_string()));
564            }
565        }
566    } else if !vsas.is_empty() || test_results > 0 {
567        raise(&mut worst, VerifyOutcome::Mismatch);
568        out.reasons.push(reason("decision_with_verification"));
569    }
570    if worst == VerifyOutcome::Ok && unsigned {
571        worst = VerifyOutcome::Unsigned;
572    }
573    out.outcome = worst;
574    out
575}
576
577/// The judge-config hash recomputed from `speccoder.toml` at the parent.
578fn policy_at(cx: &Context<'_>, object: &str) -> Option<String> {
579    let text = Git::at(cx.repo)
580        .run(&["show", &format!("{object}^:{}", cx.config_path)])
581        .ok()
582        .map(|b| String::from_utf8_lossy(&b).into_owned())?;
583    super::policy::judge_config_sha256(&text).ok()
584}
585
586/// The requirement's text hash at the commit, through the format adapter
587/// over the commit's tree: what the spec said when the task was ticked.
588fn section_hash_at(cx: &Context<'_>, object: &str, _section: &str, task: &str) -> Option<String> {
589    let dir = tempfile::tempdir().ok()?;
590    // Only what a spec format reads: the top level's files and dot
591    // directories, `openspec/`, and the tree holding the unit — never the
592    // source tree, whose size would make every check slow.
593    let git = Git::at(cx.repo);
594    let unit_top = cx
595        .tasks_path
596        .and_then(|p| p.split('/').next())
597        .unwrap_or_default();
598    let top = git
599        .run(&["ls-tree", "--format=%(objecttype) %(path)", object])
600        .map(|b| String::from_utf8_lossy(&b).into_owned())
601        .ok()?;
602    let mut paths: Vec<&str> = top
603        .lines()
604        .filter_map(|l| l.split_once(' '))
605        .filter(|(kind, path)| {
606            *kind == "blob" || path.starts_with('.') || *path == "openspec" || *path == unit_top
607        })
608        .map(|(_, path)| path)
609        .collect();
610    if cx.tasks_path.is_none() {
611        paths.clear();
612    }
613    let mut args = vec!["archive", object];
614    if !paths.is_empty() {
615        args.push("--");
616        args.extend(paths);
617    }
618    let archive = git.run(&args).ok()?;
619    let mut tar = Command::new("tar")
620        .args(["-x", "-C"])
621        .arg(dir.path())
622        .stdin(Stdio::piped())
623        .spawn()
624        .ok()?;
625    tar.stdin.take()?.write_all(&archive).ok()?;
626    tar.wait().ok()?;
627    let format = crate::format::at(dir.path());
628    let snap = format.inspect(dir.path(), Some(cx.feature))?;
629    let bundle = format.bundle(&snap, task).ok()?;
630    Some(crate::integrity::sha256_hex(bundle.story.as_bytes()))
631}
632
633/// Another noted commit with the same tree (a rebase lost the note).
634fn same_tree(repo: &Path, object: &str) -> Option<String> {
635    let git = Git::at(repo);
636    let tree = git
637        .line(&["rev-parse", &format!("{object}^{{tree}}")])
638        .ok()?;
639    notes::noted(repo).into_iter().find(|c| {
640        c != object
641            && git
642                .line(&["rev-parse", &format!("{c}^{{tree}}")])
643                .is_ok_and(|t| t == tree)
644    })
645}
646
647/// The task commits to verify: `rev` or the range's commits carrying
648/// `Spec-Task`, or every task commit on `speccoder/<feature>`.
649pub fn targets(repo: &Path, feature: &str, rev: Option<&str>) -> Result<Vec<Target>, String> {
650    let git = Git::at(repo);
651    let spec = match rev {
652        Some(r) if r.contains("..") => r.to_owned(),
653        Some(r) => {
654            let commit = git
655                .line(&["rev-parse", "--verify", &format!("{r}^{{commit}}")])
656                .map_err(|e| e.0)?;
657            let task = trailers(repo, &commit)
658                .get("Spec-Task")
659                .and_then(|t| t.rsplit('/').next().map(str::to_owned))
660                .unwrap_or_default();
661            return Ok(vec![Target {
662                object: commit,
663                kind: "task",
664                task,
665            }]);
666        }
667        None => format!("speccoder/{feature}"),
668    };
669    let log = git
670        .run(&[
671            "log",
672            "--format=%H%x1f%(trailers:key=Spec-Task,valueonly)%x1f%(trailers:key=Verdict,valueonly)%x1e",
673            &spec,
674        ])
675        .map_err(|e| e.0)?;
676    let mut out = Vec::new();
677    for record in String::from_utf8_lossy(&log).split('\u{1e}') {
678        let f: Vec<&str> = record.trim().split('\u{1f}').collect();
679        let [commit, task, verdict] = f[..] else {
680            continue;
681        };
682        let (task, verdict) = (task.trim(), verdict.trim());
683        if let Some(id) = task.strip_prefix(&format!("{feature}/"))
684            && !verdict.is_empty()
685        {
686            out.push(Target {
687                object: commit.to_owned(),
688                kind: "task",
689                task: id.to_owned(),
690            });
691        }
692    }
693    out.reverse();
694    // The feature's skips, by default: a skip is a decision on the record too.
695    if rev.is_none() {
696        let refs = git
697            .run(&[
698                "for-each-ref",
699                "--format=%(objectname) %(refname)",
700                &format!("{}/{feature}/", crate::controls::SKIPPED),
701            ])
702            .map(|b| String::from_utf8_lossy(&b).into_owned())
703            .unwrap_or_default();
704        for line in refs.lines() {
705            if let Some((object, name)) = line.split_once(' ') {
706                out.push(Target {
707                    object: object.to_owned(),
708                    kind: "skip",
709                    task: name.rsplit('/').next().unwrap_or_default().to_owned(),
710                });
711            }
712        }
713    }
714    Ok(out)
715}
716
717/// `speccoder attest verify` over `targets`.
718#[must_use]
719pub fn report(cx: &Context<'_>, targets: &[Target], require_signed: bool) -> CommandResult {
720    // Objects are independent; a few at a time, in the targets' order.
721    let width = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
722    let objects: Vec<ObjectVerification> = targets
723        .chunks(width.max(1))
724        .flat_map(|chunk| {
725            std::thread::scope(|scope| {
726                let handles: Vec<_> = chunk
727                    .iter()
728                    .map(|t| scope.spawn(move || object(cx, t)))
729                    .collect();
730                handles
731                    .into_iter()
732                    .zip(chunk)
733                    .map(|(h, t)| {
734                        h.join().unwrap_or_else(|_| ObjectVerification {
735                            object: t.object.clone(),
736                            kind: t.kind.to_owned(),
737                            task: t.task.clone(),
738                            verdict: None,
739                            outcome: VerifyOutcome::Malformed,
740                            reasons: vec![reason("check_panicked")],
741                            signer: None,
742                            statements: 0,
743                            same_tree_as: None,
744                        })
745                    })
746                    .collect::<Vec<_>>()
747            })
748        })
749        .collect();
750    // In the order the summary reads: ok first, then the worst first.
751    const KINDS: [&str; 7] = [
752        "ok",
753        "missing",
754        "malformed",
755        "bad_signature",
756        "mismatch",
757        "untrusted_signer",
758        "unsigned",
759    ];
760    let mut totals: BTreeMap<String, usize> = KINDS.iter().map(|k| ((*k).to_owned(), 0)).collect();
761    for o in &objects {
762        *totals.entry(o.outcome.word().to_owned()).or_default() += 1;
763    }
764    let failed = objects.iter().any(|o| match o.outcome {
765        VerifyOutcome::Ok => false,
766        VerifyOutcome::Unsigned => require_signed,
767        _ => true,
768    });
769    let summary: Vec<String> = KINDS
770        .iter()
771        .filter_map(|k| {
772            totals
773                .get(*k)
774                .filter(|n| **n > 0)
775                .map(|n| format!("{n} {k}"))
776        })
777        .collect();
778    let message = format!(
779        "{} task object(s): {}",
780        objects.len(),
781        if summary.is_empty() {
782            "none".to_owned()
783        } else {
784            summary.join(", ")
785        }
786    );
787    let status = if failed {
788        CommandStatus::AttestationFailed
789    } else {
790        CommandStatus::Ok
791    };
792    CommandResult::new("attest", status, &message).with_data(CommandData::Attest(Box::new(
793        AttestReport {
794            feature: cx.feature.to_owned(),
795            trust: serde_json::json!({
796                "ssh_allowed_signers": cx.trust.allowed_signers.as_ref().map(|p| p.display().to_string()),
797                "gpg": true,
798                "sigstore": cx.trust.identity,
799            }),
800            objects,
801            totals,
802        },
803    )))
804}
805
806/// `speccoder attest verify` for the project at `path`.
807#[must_use]
808pub fn verify_at(
809    path: &Path,
810    feature: Option<&str>,
811    rev: Option<&str>,
812    trust: &Trust,
813    require_signed: bool,
814) -> CommandResult {
815    let inspected = crate::inspect(path, feature);
816    let Some(CommandData::Inspect(snap)) = inspected.data() else {
817        return CommandResult::carried("attest", &inspected);
818    };
819    let Some(f) = snap.feature.as_ref() else {
820        return CommandResult::new(
821            "attest",
822            CommandStatus::SelectionNeeded,
823            inspected.message(),
824        );
825    };
826    let root = PathBuf::from(&snap.root);
827    let git = Git::at(&root);
828    let top = git
829        .toplevel()
830        .ok()
831        .flatten()
832        .unwrap_or_else(|| root.clone());
833    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
834    let rel = canonical(&root)
835        .strip_prefix(canonical(&top))
836        .map(Path::to_path_buf)
837        .unwrap_or_default();
838    let in_repo = |p: &str| rel.join(p).to_string_lossy().replace('\\', "/");
839    let tasks_path = f.tasks.as_ref().map(|t| in_repo(&t.path));
840    let config_path = crate::verify::find_config(&root)
841        .and_then(|d| {
842            canonical(&d)
843                .strip_prefix(canonical(&top))
844                .ok()
845                .map(Path::to_path_buf)
846        })
847        .map_or_else(
848            || crate::verify::config::FILE.to_owned(),
849            |d| {
850                d.join(crate::verify::config::FILE)
851                    .to_string_lossy()
852                    .replace('\\', "/")
853            },
854        );
855    let mut trust = trust.clone();
856    if trust.allowed_signers.is_none() {
857        trust.allowed_signers = git
858            .line(&["config", "gpg.ssh.allowedSignersFile"])
859            .ok()
860            .filter(|p| !p.is_empty())
861            .map(|p| match (p.strip_prefix("~/"), std::env::var_os("HOME")) {
862                (Some(rest), Some(home)) => PathBuf::from(home).join(rest),
863                _ => PathBuf::from(p),
864            });
865    }
866    let targets = match targets(&top, &f.feature.name, rev) {
867        Ok(t) => t,
868        Err(e) => return CommandResult::new("attest", CommandStatus::GitPrecondition, &e),
869    };
870    if targets.is_empty() {
871        return CommandResult::new(
872            "attest",
873            CommandStatus::NotFound,
874            &format!("no ticked task of {} to verify", f.feature.name),
875        );
876    }
877    let cx = Context {
878        repo: &top,
879        feature: &f.feature.name,
880        tasks_path: tasks_path.as_deref(),
881        config_path: &config_path,
882        trust: &trust,
883    };
884    report(&cx, &targets, require_signed)
885}