1use 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#[derive(Debug, Clone, Default)]
24pub struct Trust {
25 pub allowed_signers: Option<PathBuf>,
28 pub identity: Option<String>,
30 pub issuer: Option<String>,
32}
33
34#[derive(Debug, Clone)]
36pub struct Target {
37 pub object: String,
39 pub kind: &'static str,
41 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
63enum Checked {
65 Good(SignerView),
66 Untrusted(SignerView),
67 Bad(String),
68 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 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#[derive(Debug, PartialEq, Eq)]
171enum Gpg {
172 Bad,
174 Good {
176 fingerprint: String,
178 uid: Option<String>,
180 },
181 Untrusted {
184 fingerprint: Option<String>,
186 uid: Option<String>,
188 },
189}
190
191fn 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
307fn 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
319fn 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
337pub struct Context<'a> {
339 pub repo: &'a Path,
341 pub feature: &'a str,
343 pub tasks_path: Option<&'a str>,
345 pub config_path: &'a str,
347 pub trust: &'a Trust,
349 pub format: Option<&'static dyn crate::format::Adapter>,
352}
353
354#[must_use]
356#[allow(clippy::too_many_lines)] pub 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(¬e);
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 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 let mut unsigned = false;
416 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 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 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 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 ®ression
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 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 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 if let Some(at) = policy_at(cx, &target.object)
657 && at != claimed
658 {
659 raise(&mut worst, VerifyOutcome::Mismatch);
660 out.reasons
661 .push(differs("policy", "policy_sha256", &at, claimed));
662 }
663 }
664 _ => {
665 raise(&mut worst, VerifyOutcome::Mismatch);
666 out.reasons
667 .push(differs("bundle", "vsa", "1", &vsas.len().to_string()));
668 }
669 }
670 } else if !vsas.is_empty() || test_results > 0 {
671 raise(&mut worst, VerifyOutcome::Mismatch);
672 out.reasons.push(reason("decision_with_verification"));
673 }
674 if worst == VerifyOutcome::Ok && unsigned {
675 worst = VerifyOutcome::Unsigned;
676 }
677 out.outcome = worst;
678 out
679}
680
681fn policy_at(cx: &Context<'_>, object: &str) -> Option<String> {
683 let text = Git::at(cx.repo)
684 .run(&["show", &format!("{object}^:{}", cx.config_path)])
685 .ok()
686 .map(|b| String::from_utf8_lossy(&b).into_owned())?;
687 super::policy::judge_config_sha256(&text).ok()
688}
689
690fn section_hash_at(
693 cx: &Context<'_>,
694 object: &str,
695 _section: &str,
696 task: &str,
697) -> Result<String, String> {
698 let tops = match cx.tasks_path {
703 Some(p) => {
704 let mut tops = super::tree::intent_tops(cx.repo, object)?;
705 if let Some(unit_top) = p.split('/').next()
706 && !tops.iter().any(|t| t == unit_top)
707 {
708 tops.push(unit_top.to_owned());
709 }
710 Some(tops)
711 }
712 None => None,
713 };
714 let dir = super::tree::materialize(cx.repo, object, tops.as_deref())?;
715 let format = crate::format::at(dir.path());
716 let snap = format
717 .inspect(dir.path(), Some(cx.feature))
718 .ok_or_else(|| format!("the commit holds no unit {}", cx.feature))?;
719 let bundle = format
720 .bundle(&snap, task)
721 .map_err(|e| format!("the commit's unit holds no task {task}: {e:?}"))?;
722 Ok(crate::integrity::sha256_hex(bundle.story.as_bytes()))
723}
724
725fn same_tree(repo: &Path, object: &str) -> Option<String> {
727 let git = Git::at(repo);
728 let tree = git
729 .line(&["rev-parse", &format!("{object}^{{tree}}")])
730 .ok()?;
731 notes::noted(repo).into_iter().find(|c| {
732 c != object
733 && git
734 .line(&["rev-parse", &format!("{c}^{{tree}}")])
735 .is_ok_and(|t| t == tree)
736 })
737}
738
739pub fn targets(repo: &Path, feature: &str, rev: Option<&str>) -> Result<Vec<Target>, String> {
742 let git = Git::at(repo);
743 let spec = match rev {
744 Some(r) if r.contains("..") => r.to_owned(),
745 Some(r) => {
746 let commit = git
747 .line(&["rev-parse", "--verify", &format!("{r}^{{commit}}")])
748 .map_err(|e| e.0)?;
749 let task = trailers(repo, &commit)
750 .get("Spec-Task")
751 .and_then(|t| t.rsplit('/').next().map(str::to_owned))
752 .unwrap_or_default();
753 return Ok(vec![Target {
754 object: commit,
755 kind: "task",
756 task,
757 }]);
758 }
759 None => format!("speccoder/{feature}"),
760 };
761 let log = git
762 .run(&[
763 "log",
764 "--format=%H%x1f%(trailers:key=Spec-Task,valueonly)%x1f%(trailers:key=Verdict,valueonly)%x1e",
765 &spec,
766 ])
767 .map_err(|e| e.0)?;
768 let mut out = Vec::new();
769 for record in String::from_utf8_lossy(&log).split('\u{1e}') {
770 let f: Vec<&str> = record.trim().split('\u{1f}').collect();
771 let [commit, task, verdict] = f[..] else {
772 continue;
773 };
774 let (task, verdict) = (task.trim(), verdict.trim());
775 if let Some(id) = task.strip_prefix(&format!("{feature}/"))
776 && !verdict.is_empty()
777 {
778 out.push(Target {
779 object: commit.to_owned(),
780 kind: "task",
781 task: id.to_owned(),
782 });
783 }
784 }
785 out.reverse();
786 if rev.is_none() {
788 let refs = git
789 .run(&[
790 "for-each-ref",
791 "--format=%(objectname) %(refname)",
792 &format!("{}/{feature}/", notes::SKIPPED),
793 ])
794 .map(|b| String::from_utf8_lossy(&b).into_owned())
795 .unwrap_or_default();
796 for line in refs.lines() {
797 if let Some((object, name)) = line.split_once(' ') {
798 out.push(Target {
799 object: object.to_owned(),
800 kind: "skip",
801 task: name.rsplit('/').next().unwrap_or_default().to_owned(),
802 });
803 }
804 }
805 }
806 Ok(out)
807}
808
809#[must_use]
811pub fn report(cx: &Context<'_>, targets: &[Target], require_signed: bool) -> CommandResult {
812 let width = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
814 let objects: Vec<ObjectVerification> = targets
815 .chunks(width.max(1))
816 .flat_map(|chunk| {
817 std::thread::scope(|scope| {
818 let handles: Vec<_> = chunk
819 .iter()
820 .map(|t| scope.spawn(move || object(cx, t)))
821 .collect();
822 handles
823 .into_iter()
824 .zip(chunk)
825 .map(|(h, t)| {
826 h.join().unwrap_or_else(|_| ObjectVerification {
827 object: t.object.clone(),
828 kind: t.kind.to_owned(),
829 task: t.task.clone(),
830 verdict: None,
831 outcome: VerifyOutcome::Malformed,
832 reasons: vec![reason("check_panicked")],
833 signer: None,
834 statements: 0,
835 same_tree_as: None,
836 })
837 })
838 .collect::<Vec<_>>()
839 })
840 })
841 .collect();
842 const KINDS: [&str; 8] = [
844 "ok",
845 "missing",
846 "malformed",
847 "bad_signature",
848 "mismatch",
849 "tool_missing",
850 "untrusted_signer",
851 "unsigned",
852 ];
853 let mut totals: BTreeMap<String, usize> = KINDS.iter().map(|k| ((*k).to_owned(), 0)).collect();
854 for o in &objects {
855 *totals.entry(o.outcome.word().to_owned()).or_default() += 1;
856 }
857 let failed = objects.iter().any(|o| match o.outcome {
858 VerifyOutcome::Ok => false,
859 VerifyOutcome::Unsigned => require_signed,
860 _ => true,
861 });
862 let summary: Vec<String> = KINDS
863 .iter()
864 .filter_map(|k| {
865 totals
866 .get(*k)
867 .filter(|n| **n > 0)
868 .map(|n| format!("{n} {k}"))
869 })
870 .collect();
871 let message = format!(
872 "{} task object(s): {}",
873 objects.len(),
874 if summary.is_empty() {
875 "none".to_owned()
876 } else {
877 summary.join(", ")
878 }
879 );
880 let status = if failed {
881 CommandStatus::AttestationFailed
882 } else {
883 CommandStatus::Ok
884 };
885 CommandResult::new("attest", status, &message).with_data(CommandData::Attest(Box::new(
886 AttestReport {
887 feature: cx.feature.to_owned(),
888 trust: serde_json::json!({
889 "ssh_allowed_signers": cx.trust.allowed_signers.as_ref().map(|p| p.display().to_string()),
890 "gpg": true,
891 "sigstore": cx.trust.identity,
892 }),
893 objects,
894 totals,
895 },
896 )))
897}
898
899#[must_use]
901pub fn verify_at(
902 path: &Path,
903 feature: Option<&str>,
904 rev: Option<&str>,
905 trust: &Trust,
906 require_signed: bool,
907) -> CommandResult {
908 let inspected = crate::inspect(path, feature);
909 let Some(CommandData::Inspect(snap)) = inspected.data() else {
910 return CommandResult::carried("attest", &inspected);
911 };
912 let Some(f) = snap.feature.as_ref() else {
913 return CommandResult::new(
914 "attest",
915 CommandStatus::SelectionNeeded,
916 inspected.message(),
917 );
918 };
919 let root = PathBuf::from(&snap.root);
920 let git = Git::at(&root);
921 let top = git
922 .toplevel()
923 .ok()
924 .flatten()
925 .unwrap_or_else(|| root.clone());
926 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
927 let rel = canonical(&root)
928 .strip_prefix(canonical(&top))
929 .map(Path::to_path_buf)
930 .unwrap_or_default();
931 let in_repo = |p: &str| rel.join(p).to_string_lossy().replace('\\', "/");
932 let tasks_path = f.tasks.as_ref().map(|t| in_repo(&t.path));
933 let config_path = crate::verify::find_config(&root)
934 .and_then(|d| {
935 canonical(&d)
936 .strip_prefix(canonical(&top))
937 .ok()
938 .map(Path::to_path_buf)
939 })
940 .map_or_else(
941 || crate::verify::config::FILE.to_owned(),
942 |d| {
943 d.join(crate::verify::config::FILE)
944 .to_string_lossy()
945 .replace('\\', "/")
946 },
947 );
948 let mut trust = trust.clone();
949 if trust.allowed_signers.is_none() {
950 trust.allowed_signers = git
951 .line(&["config", "gpg.ssh.allowedSignersFile"])
952 .ok()
953 .filter(|p| !p.is_empty())
954 .map(|p| match (p.strip_prefix("~/"), std::env::var_os("HOME")) {
955 (Some(rest), Some(home)) => PathBuf::from(home).join(rest),
956 _ => PathBuf::from(p),
957 });
958 }
959 let targets = match targets(&top, &f.feature.name, rev) {
960 Ok(t) => t,
961 Err(e) => return CommandResult::new("attest", CommandStatus::GitPrecondition, &e),
962 };
963 if targets.is_empty() {
964 return CommandResult::new(
965 "attest",
966 CommandStatus::NotFound,
967 &format!("no ticked task of {} to verify", f.feature.name),
968 );
969 }
970 let cx = Context {
971 repo: &top,
972 feature: &f.feature.name,
973 tasks_path: tasks_path.as_deref(),
974 config_path: &config_path,
975 trust: &trust,
976 format: None,
977 };
978 report(&cx, &targets, require_signed)
979}
980
981#[cfg(test)]
982mod tests {
983 use super::*;
984
985 const FPR: &str = "0123456789ABCDEF0123456789ABCDEF01234567";
986
987 fn status(uid: &str, trust: &str) -> String {
988 format!(
989 "[GNUPG:] NEWSIG\n[GNUPG:] KEY_CONSIDERED {FPR} 0\n[GNUPG:] SIG_ID abc 2026-10-26 1793000000\n\
990 [GNUPG:] GOODSIG 89ABCDEF01234567 {uid}\n\
991 [GNUPG:] VALIDSIG {FPR} 2026-10-26 1793000000 0 4 0 22 10 00 {FPR}\n\
992 [GNUPG:] {trust} 0 pgp\n"
993 )
994 }
995
996 #[test]
997 fn a_fully_trusted_good_signature_is_good() {
998 assert_eq!(
999 gpg_status(&status("Ada <ada@example.com>", "TRUST_ULTIMATE"), true),
1000 Gpg::Good {
1001 fingerprint: FPR.to_owned(),
1002 uid: Some("Ada <ada@example.com>".to_owned()),
1003 }
1004 );
1005 assert!(matches!(
1006 gpg_status(&status("Ada <ada@example.com>", "TRUST_FULLY"), true),
1007 Gpg::Good { .. }
1008 ));
1009 }
1010
1011 #[test]
1012 fn trust_words_in_the_signers_user_id_are_not_trust() {
1013 for uid in [
1016 "Eve TRUST_ULTIMATE <eve@example.com>",
1017 "Eve [GNUPG:] TRUST_FULLY 0 pgp",
1018 "TRUST_ULTIMATE",
1019 ] {
1020 assert!(
1021 matches!(
1022 gpg_status(&status(uid, "TRUST_UNDEFINED"), true),
1023 Gpg::Untrusted { .. }
1024 ),
1025 "{uid}"
1026 );
1027 }
1028 let forged = status("Eve\n[GNUPG:] TRUST_ULTIMATE 0 pgp", "TRUST_UNDEFINED");
1030 assert!(matches!(gpg_status(&forged, true), Gpg::Untrusted { .. }));
1031 }
1032
1033 #[test]
1034 fn only_one_good_signature_by_the_key_gpg_validated_is_good() {
1035 let s = status("Ada", "TRUST_ULTIMATE");
1036 assert!(matches!(gpg_status(&s, false), Gpg::Untrusted { .. }));
1039 assert_eq!(
1040 gpg_status(&format!("{s}[GNUPG:] BADSIG 89ABCDEF01234567 Ada\n"), true),
1041 Gpg::Bad
1042 );
1043 assert!(matches!(
1044 gpg_status(
1045 &format!("{s}[GNUPG:] EXPKEYSIG 89ABCDEF01234567 Ada\n"),
1046 true
1047 ),
1048 Gpg::Untrusted { .. }
1049 ));
1050 let no_valid: String = s
1051 .lines()
1052 .filter(|l| !l.contains("VALIDSIG"))
1053 .map(|l| format!("{l}\n"))
1054 .collect();
1055 assert!(matches!(gpg_status(&no_valid, true), Gpg::Untrusted { .. }));
1056 let other = s.replace("GOODSIG 89ABCDEF01234567", "GOODSIG FFFFFFFFFFFFFFFF");
1057 assert!(matches!(gpg_status(&other, true), Gpg::Untrusted { .. }));
1058 let indented = s.replace("[GNUPG:] TRUST_ULTIMATE", "x [GNUPG:] TRUST_ULTIMATE");
1060 assert!(matches!(gpg_status(&indented, true), Gpg::Untrusted { .. }));
1061 }
1062}