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}
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 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 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
241fn 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
253fn 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
271pub struct Context<'a> {
273 pub repo: &'a Path,
275 pub feature: &'a str,
277 pub tasks_path: Option<&'a str>,
279 pub config_path: &'a str,
281 pub trust: &'a Trust,
283}
284
285#[must_use]
287#[allow(clippy::too_many_lines)] pub 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(¬e);
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 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 let mut unsigned = false;
347 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 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 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 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
577fn 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
586fn section_hash_at(cx: &Context<'_>, object: &str, _section: &str, task: &str) -> Option<String> {
589 let dir = tempfile::tempdir().ok()?;
590 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
633fn 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
647pub 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 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#[must_use]
719pub fn report(cx: &Context<'_>, targets: &[Target], require_signed: bool) -> CommandResult {
720 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 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#[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}