1use std::collections::{BTreeMap, BTreeSet};
13use std::path::{Path, PathBuf};
14
15use serde_json::{Value, json};
16use speccoder_core::predicate::TaskVerificationV0;
17use speccoder_core::verify_branch::{
18 self, Anchor, BranchReport, BranchTotals, Conclusion, Mode, SkippedTask, Tick, TickKind,
19 TickOutcome, TickReport, Unverifiable,
20};
21use speccoder_core::{
22 CommandData, CommandResult, CommandStatus, ObjectVerification, TraceRow, VerifyOutcome,
23};
24
25use super::verify::{Context, Target, Trust};
26use super::{notes, statement};
27use crate::format::Adapter;
28use crate::git::Git;
29
30const FS: char = '\u{1f}';
32const RS: char = '\u{1e}';
33
34#[derive(Debug, Clone)]
36pub enum AllowedSigners {
37 File(PathBuf),
39 Rev {
42 rev: String,
44 path: String,
46 },
47}
48
49impl AllowedSigners {
50 pub fn parse_rev(spec: &str) -> Result<Self, String> {
56 match spec.split_once(':') {
57 Some((rev, path)) if !rev.is_empty() && !path.is_empty() => Ok(Self::Rev {
58 rev: rev.to_owned(),
59 path: path.to_owned(),
60 }),
61 _ => Err(format!(
62 "--allowed-signers-rev takes <rev>:<path>, e.g. origin/main:.github/speccoder/allowed_signers (got `{spec}`)"
63 )),
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct Options {
71 pub head: String,
73 pub base: Option<String>,
76 pub all: bool,
78 pub allowed_signers: Option<AllowedSigners>,
80 pub identity: Option<String>,
82 pub issuer: Option<String>,
84 pub require_signed: bool,
86 pub require_verified: bool,
88}
89
90impl Default for Options {
91 fn default() -> Self {
92 Self {
93 head: "HEAD".to_owned(),
94 base: None,
95 all: false,
96 allowed_signers: None,
97 identity: None,
98 issuer: None,
99 require_signed: true,
100 require_verified: false,
101 }
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Refusal {
108 pub status: CommandStatus,
110 pub message: String,
112}
113
114impl Refusal {
115 fn internal(what: impl std::fmt::Display) -> Self {
118 Self::new(
119 CommandStatus::Error,
120 format!("the check could not read the branch: {what}; nothing was verified"),
121 )
122 }
123
124 fn new(status: CommandStatus, message: impl Into<String>) -> Self {
125 Self {
126 status,
127 message: message.into(),
128 }
129 }
130}
131
132pub const NO_TRUST: &str = "no trust configured: pass --allowed-signers or --certificate-identity/--certificate-oidc-issuer";
134
135#[derive(Debug)]
137pub struct Checked {
138 pub report: BranchReport,
140 pub repo: PathBuf,
142 pub prefix: String,
144}
145
146#[must_use]
150pub fn verify(start: &Path, opts: &Options, command: &str) -> CommandResult {
151 match check(start, opts) {
152 Ok(c) => result(command, c.report),
153 Err(r) => CommandResult::new(command, r.status, &r.message),
154 }
155}
156
157#[must_use]
159pub fn result(command: &str, report: BranchReport) -> CommandResult {
160 let status = match report.conclusion {
161 Conclusion::Success => CommandStatus::Ok,
162 Conclusion::Failure => CommandStatus::AttestationFailed,
163 Conclusion::Neutral => CommandStatus::NotFound,
164 };
165 let mut message = report.title();
166 if report.accepted_unsigned {
167 message.push_str("; unsigned ticks were accepted (--require-signed=false)");
168 }
169 CommandResult::new(command, status, &message)
170 .with_data(CommandData::BranchVerify(Box::new(report)))
171}
172
173fn trust(
178 top: &Path,
179 opts: &Options,
180 optional: bool,
181) -> Result<(Option<Trust>, Value, Option<tempfile::TempDir>), Refusal> {
182 let sigstore = opts.identity.is_some() && opts.issuer.is_some();
183 if opts.identity.is_some() != opts.issuer.is_some() {
184 return Err(Refusal::new(
185 CommandStatus::UsageError,
186 "--certificate-identity and --certificate-oidc-issuer go together",
187 ));
188 }
189 let mut held = None;
190 let (allowed, source) = match &opts.allowed_signers {
191 None => (None, Value::Null),
192 Some(AllowedSigners::File(p)) => {
193 if !p.is_file() {
194 return Err(Refusal::new(
195 CommandStatus::UsageError,
196 format!("--allowed-signers {}: no such file", p.display()),
197 ));
198 }
199 (Some(p.clone()), json!({ "file": p.display().to_string() }))
200 }
201 Some(AllowedSigners::Rev { rev, path }) => {
202 let bytes = Git::at(top)
203 .run(&["show", &format!("{rev}:{path}")])
204 .map_err(|e| {
205 Refusal::new(
206 CommandStatus::UsageError,
207 format!(
208 "--allowed-signers-rev {rev}:{path}: cannot read it ({})",
209 e.0
210 ),
211 )
212 })?;
213 let dir = tempfile::tempdir().map_err(|e| {
214 Refusal::new(CommandStatus::Error, format!("no temporary directory: {e}"))
215 })?;
216 let file = dir.path().join("allowed_signers");
217 std::fs::write(&file, bytes).map_err(|e| {
218 Refusal::new(CommandStatus::Error, format!("cannot write the trust: {e}"))
219 })?;
220 held = Some(dir);
221 (Some(file), json!({ "rev": rev, "path": path }))
222 }
223 };
224 if allowed.is_none() && !sigstore {
225 if optional {
226 return Ok((
227 None,
228 json!({ "signatures": "not checked: no trust given" }),
229 None,
230 ));
231 }
232 return Err(Refusal::new(CommandStatus::UsageError, NO_TRUST));
233 }
234 let shown = json!({
235 "ssh_allowed_signers": source,
236 "gpg": true,
237 "sigstore": opts.identity.as_ref().map(|i| json!({ "identity": i, "issuer": opts.issuer })),
238 });
239 Ok((
240 Some(Trust {
241 allowed_signers: allowed,
242 identity: opts.identity.clone(),
243 issuer: opts.issuer.clone(),
244 }),
245 shown,
246 held,
247 ))
248}
249
250fn relative(path: &Path, root: &Path) -> Option<String> {
252 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
253 let rel = canonical(path)
254 .strip_prefix(canonical(root))
255 .ok()?
256 .to_path_buf();
257 Some(rel.to_string_lossy().replace('\\', "/"))
258}
259
260fn as_prefix(rel: &str) -> String {
262 if rel.is_empty() {
263 String::new()
264 } else {
265 format!("{}/", rel.trim_end_matches('/'))
266 }
267}
268
269struct History {
273 latest: BTreeMap<String, String>,
274 bug_claims: BTreeSet<String>,
275}
276
277fn history(git: &Git, head: &str, base: Option<&str>) -> Result<History, Refusal> {
278 let log = git
279 .run(&[
280 "log",
281 "--grep=^Spec-Task: ",
282 "--format=%H%x1f%(trailers:key=Spec-Task,valueonly)%x1f%(trailers:key=Verdict,valueonly)%x1e",
283 head,
284 ])
285 .map(|b| String::from_utf8_lossy(&b).into_owned())
286 .map_err(|e| Refusal::internal(format!("git log {head}: {}", e.0)))?;
287 let in_range: Option<BTreeSet<String>> = base
288 .map(|b| {
289 git.run(&["rev-list", &format!("{b}..{head}")])
290 .map(|o| {
291 String::from_utf8_lossy(&o)
292 .lines()
293 .map(str::to_owned)
294 .collect()
295 })
296 .map_err(|e| Refusal::internal(format!("git rev-list {b}..{head}: {}", e.0)))
297 })
298 .transpose()?;
299 let mut latest = BTreeMap::new();
300 let mut bug_claims = BTreeSet::new();
301 for record in log.split(RS) {
302 let f: Vec<&str> = record.trim().split(FS).collect();
303 let [commit, task, verdict] = f[..] else {
304 continue;
305 };
306 let (task, verdict) = (task.trim(), verdict.trim());
307 if task.is_empty() || verdict.is_empty() {
308 continue;
309 }
310 if task.contains("/bug:") && in_range.as_ref().is_none_or(|r| r.contains(commit)) {
311 bug_claims.insert(task.to_owned());
312 }
313 latest
314 .entry(task.to_owned())
315 .or_insert_with(|| commit.to_owned());
316 }
317 Ok(History { latest, bug_claims })
318}
319
320fn unreachable_evidence(
324 git: &Git,
325 repo: &Path,
326 head: &str,
327) -> Result<BTreeMap<String, String>, Refusal> {
328 let reachable: BTreeSet<String> = git
329 .run(&["rev-list", head])
330 .map(|o| {
331 String::from_utf8_lossy(&o)
332 .lines()
333 .map(str::to_owned)
334 .collect()
335 })
336 .map_err(|e| Refusal::internal(format!("git rev-list {head}: {}", e.0)))?;
337 let noted = notes::try_noted(repo)
338 .map_err(|e| Refusal::internal(format!("git notes list: {}", e.0)))?;
339 let mut out = BTreeMap::new();
340 for commit in noted {
341 if reachable.contains(&commit) {
342 continue;
343 }
344 for s in crate::evidence::statements(repo, &commit) {
345 if s["predicateType"] == statement::TASK_VERIFICATION
346 && let Some(name) = s["subject"][0]["name"].as_str()
347 {
348 out.insert(name.to_owned(), commit.clone());
349 }
350 }
351 }
352 Ok(out)
353}
354
355fn changed(git: &Git, base: &str, head: &str) -> Result<Vec<String>, Refusal> {
358 git.run(&["diff", "--name-only", "-z", base, head])
359 .map(|o| crate::git::nul_fields(&o))
360 .map_err(|e| Refusal::internal(format!("git diff {base} {head}: {}", e.0)))
361}
362
363fn project_at(top: &Path, rev: &str, start_rel: &str) -> Result<bool, Refusal> {
365 let dir = super::tree::intent_tops(top, rev)
366 .and_then(|tops| super::tree::materialize(top, rev, Some(&tops)))
367 .map_err(Refusal::internal)?;
368 let at = dir.path().join(start_rel);
369 Ok(crate::format::at(&at).inspect(&at, None).is_some())
370}
371
372struct Unit {
374 name: String,
375 dir: String,
376 ticks: Vec<Tick>,
377 skipped: Vec<SkippedTask>,
378}
379
380fn unit(
383 format: &dyn Adapter,
384 project: &Path,
385 name: &str,
386 prefix: &str,
387 skips: &BTreeSet<String>,
388) -> Result<Unit, Refusal> {
389 let f = format
390 .inspect(project, Some(name))
391 .and_then(|snap| snap.feature)
392 .ok_or_else(|| Refusal::internal(format!("the unit {name} cannot be read at the head")))?;
393 let mut ticks = Vec::new();
394 let mut skipped = Vec::new();
395 if let Some(tasks) = &f.tasks {
396 let text = std::fs::read_to_string(project.join(&tasks.path))
397 .map_err(|e| Refusal::internal(format!("{prefix}{}: {e}", tasks.path)))?;
398 let lines: Vec<&str> = text.lines().collect();
399 for t in &tasks.tasks {
400 if !t.done {
401 if skips.contains(&format!("{name}/{}", t.id)) {
402 skipped.push(SkippedTask {
403 feature: name.to_owned(),
404 task: t.id.clone(),
405 });
406 }
407 continue;
408 }
409 let line = usize::try_from(t.line)
410 .ok()
411 .and_then(|l| l.checked_sub(1))
412 .and_then(|i| lines.get(i))
413 .copied()
414 .unwrap_or_default();
415 ticks.push(Tick {
416 feature: name.to_owned(),
417 task: t.id.clone(),
418 title: t.title.clone(),
419 kind: TickKind::Tick,
420 anchor: Anchor {
421 path: format!("{prefix}{}", tasks.path),
422 line: t.line,
423 },
424 line_sha256: Some(crate::integrity::sha256_hex(&crate::integrity::untick(
425 line.as_bytes(),
426 ))),
427 claim_sha256: None,
428 });
429 }
430 }
431 Ok(Unit {
432 name: name.to_owned(),
433 dir: f.feature.dir,
434 ticks,
435 skipped,
436 })
437}
438
439fn trailer_only(format: &dyn Adapter, project: &Path, prefix: &str, claim: &str) -> Option<Tick> {
443 let (unit, task) = claim.rsplit_once('/')?;
444 let slug = task
447 .strip_prefix("bug:")
448 .filter(|s| speccoder_core::bug::valid_slug(s))?;
449 let home = format.bug_unit(project).ok().filter(|h| h.kind.native())?;
450 if format.bug_dir(&home, slug).map(|(_, u)| u).as_deref() != Some(unit) {
451 return None;
452 }
453 let read = format.read_bug(project, &home, slug).ok()?;
454 let triad = speccoder_core::bug::Triad::new(
455 read.current.first().map_or("", String::as_str),
456 &read.expected,
457 &read.unchanged,
458 crate::integrity::sha256_hex,
459 )
460 .ok()?;
461 let text = std::fs::read_to_string(project.join(&read.file)).unwrap_or_default();
462 let line = text
463 .lines()
464 .position(|l| l.contains("Expected:"))
465 .and_then(|i| u32::try_from(i + 1).ok())
466 .unwrap_or(1);
467 Some(Tick {
468 feature: unit.to_owned(),
469 task: task.to_owned(),
470 title: read.title.clone().unwrap_or_else(|| triad.title()),
471 kind: TickKind::TrailerOnly,
472 anchor: Anchor {
473 path: format!("{prefix}{}", read.file),
474 line,
475 },
476 line_sha256: None,
477 claim_sha256: Some(triad.digest),
478 })
479}
480
481fn skips(git: &Git) -> Result<BTreeSet<String>, Refusal> {
483 git.run(&[
484 "for-each-ref",
485 "--format=%(refname)",
486 &format!("{}/", notes::SKIPPED),
487 ])
488 .map(|o| {
489 String::from_utf8_lossy(&o)
490 .lines()
491 .filter_map(|r| r.strip_prefix(&format!("{}/", notes::SKIPPED)))
492 .map(|r| r.replace("%3A", ":").replace("%25", "%"))
494 .collect()
495 })
496 .map_err(|e| Refusal::internal(format!("git for-each-ref {}: {}", notes::SKIPPED, e.0)))
497}
498
499fn detail(v: &ObjectVerification) -> Option<String> {
501 let r = v.reasons.first()?;
502 Some(match (&r.field, &r.expected, &r.found) {
503 (Some(field), Some(e), Some(f)) => format!("{} {field}: expected {e}, found {f}", r.code),
504 (_, _, Some(f)) => format!("{}: {f}", r.code),
505 _ => r.code.clone(),
506 })
507}
508
509fn outcome_of(
512 tick: &Tick,
513 commit: &str,
514 v: ObjectVerification,
515 statements: &[Value],
516 require_signed: bool,
517) -> TickReport {
518 let mut r = TickReport {
519 tick: tick.clone(),
520 commit: Some(commit.to_owned()),
521 outcome: TickOutcome::Evidenced,
522 verdict: v.verdict.clone(),
523 reason: None,
524 detail: None,
525 signer: v.signer.clone(),
526 decider: None,
527 stale: Vec::new(),
528 stale_anchor: None,
529 regression: None,
530 newer_fields: Vec::new(),
531 verification: None,
532 };
533 let unverifiable = |r: &mut TickReport, reason: Unverifiable, detail: Option<String>| {
534 r.outcome = TickOutcome::Unverifiable;
535 r.reason = Some(reason);
536 r.detail = detail;
537 };
538 let other = statements.iter().find_map(|s| {
540 s["predicateType"]
541 .as_str()
542 .filter(|t| t.starts_with("https://speccoder.dev/task-verification/"))
543 .filter(|t| *t != statement::TASK_VERIFICATION)
544 });
545 let tv = statements
546 .iter()
547 .find(|s| s["predicateType"] == statement::TASK_VERIFICATION)
548 .map(|s| serde_json::from_value::<TaskVerificationV0>(s["predicate"].clone()));
549 match v.outcome {
550 VerifyOutcome::Ok => {}
551 VerifyOutcome::Unsigned if !require_signed => {}
552 o => {
553 let reason = Unverifiable::of(o).unwrap_or(Unverifiable::Malformed);
554 match other {
555 Some(t) if tv.is_none() => unverifiable(
556 &mut r,
557 Unverifiable::Malformed,
558 Some(format!("predicate {t} not supported by this verifier")),
559 ),
560 _ => unverifiable(&mut r, reason, detail(&v)),
561 }
562 }
563 }
564 if r.outcome == TickOutcome::Evidenced {
565 match tv {
566 Some(Ok(tv)) => {
567 let triad = tv
572 .bug
573 .as_ref()
574 .and_then(|b| b["triad_sha256"].as_str().map(str::to_owned));
575 let agree = statements
576 .iter()
577 .filter(|s| s["predicateType"] == statement::TASK_VERIFICATION)
578 .all(|s| s["predicate"]["bug"]["triad_sha256"].as_str() == triad.as_deref());
579 let anchored = match tick.kind {
580 TickKind::Tick => Some(&tv.task.line_hash) == tick.line_sha256.as_ref(),
581 TickKind::TrailerOnly => triad.is_some() && agree,
582 };
583 if tick.kind == TickKind::TrailerOnly && triad.is_some() && !agree {
584 unverifiable(
585 &mut r,
586 Unverifiable::Mismatch,
587 Some(
588 "the note's task-verification statements name different triads"
589 .to_owned(),
590 ),
591 );
592 } else if !anchored {
593 unverifiable(
594 &mut r,
595 Unverifiable::Mismatch,
596 Some("the task line changed since its tick: task.line_hash differs from the line at the head".to_owned()),
597 );
598 }
599 if r.outcome == TickOutcome::Evidenced
601 && tick.kind == TickKind::TrailerOnly
602 && triad.as_deref() != tick.claim_sha256.as_deref()
603 {
604 r.outcome = TickOutcome::Stale;
605 r.stale_anchor = Some(tick.anchor.clone());
606 r.detail = Some(
607 "the bug's triad changed since its fix: the verdict is for the earlier words"
608 .to_owned(),
609 );
610 }
611 r.newer_fields = tv.newer_fields();
612 r.regression = tv
613 .regression
614 .as_ref()
615 .map(speccoder_core::regression::wording);
616 r.decider = tv.decider.map(|d| d.by);
617 }
618 Some(Err(e)) => unverifiable(
619 &mut r,
620 Unverifiable::Malformed,
621 Some(format!("the predicate is not task-verification v0: {e}")),
622 ),
623 None => unverifiable(
624 &mut r,
625 Unverifiable::Mismatch,
626 Some("no task-verification statement".to_owned()),
627 ),
628 }
629 }
630 if v.outcome == VerifyOutcome::Unsigned && require_signed {
631 r.detail =
632 Some("the statements are not signed (sign with `speccoder attest sign`)".to_owned());
633 }
634 r.verification = Some(v);
635 r
636}
637
638fn without_commit(tick: &Tick, lost: Option<&String>) -> TickReport {
640 let (reason, detail) = match lost {
641 Some(c) => (
642 Unverifiable::CommitUnreachable,
643 format!(
644 "its evidence is on {} — not reachable from the head (a squash or rebase merge?); merge with a merge commit to keep it",
645 &c[..c.len().min(12)]
646 ),
647 ),
648 None => (
649 Unverifiable::TickedWithoutEvidence,
650 "no commit carries this task's verdict (ticked by hand?)".to_owned(),
651 ),
652 };
653 TickReport {
654 tick: tick.clone(),
655 commit: None,
656 outcome: TickOutcome::Unverifiable,
657 verdict: None,
658 reason: Some(reason),
659 detail: Some(detail),
660 signer: None,
661 decider: None,
662 stale: Vec::new(),
663 stale_anchor: None,
664 regression: None,
665 newer_fields: Vec::new(),
666 verification: None,
667 }
668}
669
670fn line_of(text: &str, line: &str) -> Option<u32> {
672 let want = line.trim();
673 text.lines()
674 .position(|l| l.trim() == want)
675 .and_then(|i| u32::try_from(i + 1).ok())
676}
677
678fn mark_stale(
681 top: &Path,
682 format: &dyn Adapter,
683 project: &Path,
684 prefix: &str,
685 unit_dir: &str,
686 head: &str,
687 reports: &mut [TickReport],
688) {
689 let asked: Vec<(&str, Option<&str>)> = reports
690 .iter()
691 .map(|r| {
692 (
693 r.tick.title.as_str(),
694 r.commit
695 .as_deref()
696 .filter(|_| r.outcome == TickOutcome::Evidenced),
697 )
698 })
699 .collect();
700 let Some(found) = crate::drift::at(top, format, unit_dir, prefix, head, &asked) else {
701 return;
702 };
703 let spec = format
704 .requirements_file()
705 .map(|f| format!("{}/{f}", unit_dir.trim_end_matches('/')));
706 let spec_text = spec
707 .as_ref()
708 .and_then(|s| std::fs::read_to_string(project.join(s)).ok())
709 .unwrap_or_default();
710 for (r, drift) in reports.iter_mut().zip(found) {
711 let Some(drift) = drift.filter(|d| !d.requirements.is_empty()) else {
712 continue;
713 };
714 r.outcome = TickOutcome::Stale;
715 r.stale_anchor = drift.requirements.iter().find_map(|d| {
716 let now = d.now.as_deref()?;
717 Some(Anchor {
718 path: format!("{prefix}{}", spec.as_deref()?),
719 line: line_of(&spec_text, now)?,
720 })
721 });
722 r.detail = Some(format!(
723 "{} changed since the tick: the verdict is for the earlier wording",
724 drift
725 .requirements
726 .iter()
727 .map(|d| d.id.as_str())
728 .collect::<Vec<_>>()
729 .join(", ")
730 ));
731 r.stale = drift.requirements;
732 }
733}
734
735fn word(r: &TickReport) -> String {
737 match r.outcome {
738 TickOutcome::Evidenced => r.verdict.clone().unwrap_or_default(),
739 TickOutcome::Stale => "stale".to_owned(),
740 TickOutcome::Unverifiable => "unverifiable".to_owned(),
741 TickOutcome::Regressed => "regressed".to_owned(),
742 }
743}
744
745fn trace(
749 requirements: &[(String, String)],
750 reports: &[TickReport],
751 signed: &BTreeMap<String, Vec<Value>>,
752) -> Vec<TraceRow> {
753 let mut rows = Vec::new();
754 for (id, _) in requirements {
755 let citing: Vec<&TickReport> = reports
756 .iter()
757 .filter(|r| super::cites(&r.tick.title, id))
758 .collect();
759 if citing.is_empty() {
760 rows.push(TraceRow {
761 requirement: id.clone(),
762 scenario: None,
763 test: None,
764 task: None,
765 verdict: None,
766 changed: false,
767 inherited: None,
768 });
769 }
770 for r in citing {
771 rows.push(TraceRow {
772 requirement: id.clone(),
773 scenario: None,
774 test: None,
775 task: Some(r.tick.task.clone()),
776 verdict: Some(word(r)),
777 changed: r.stale.iter().any(|d| d.id == *id),
778 inherited: None,
779 });
780 }
781 }
782 for r in reports {
783 for row in signed.get(&r.tick.task).into_iter().flatten() {
784 let Some(test) = row["test"].as_str() else {
785 continue;
786 };
787 rows.push(TraceRow {
788 requirement: row["requirement"].as_str().unwrap_or("—").to_owned(),
789 scenario: row["scenario"].as_str().map(str::to_owned),
790 test: Some(test.to_owned()),
791 task: Some(r.tick.task.clone()),
792 verdict: Some(word(r)),
793 changed: false,
794 inherited: None,
795 });
796 }
797 }
798 rows
799}
800
801pub fn check(start: &Path, opts: &Options) -> Result<Checked, Refusal> {
807 run(start, opts, None)
808}
809
810pub fn locate(start: &Path, opts: &Options, feature: &str, task: &str) -> Result<Checked, Refusal> {
818 let opts = Options {
819 all: true,
820 base: None,
821 ..opts.clone()
822 };
823 run(start, &opts, Some((feature, Some(task))))
824}
825
826pub fn recorded(start: &Path, head: &str, feature: &str) -> Result<Checked, Refusal> {
834 let opts = Options {
835 head: head.to_owned(),
836 all: true,
837 ..Options::default()
838 };
839 run(start, &opts, Some((feature, None)))
840}
841
842#[must_use]
846pub fn closed(start: &Path, head: &str, feature: &str) -> Vec<(String, String)> {
847 recorded(start, head, feature)
848 .map(|c| {
849 c.report
850 .ticks
851 .into_iter()
852 .filter_map(|t| t.commit.map(|c| (t.tick.task, c)))
853 .collect()
854 })
855 .unwrap_or_default()
856}
857
858fn as_recorded(commit: &str, task: &str, statements: &[Value]) -> ObjectVerification {
861 let tv = statements
862 .iter()
863 .find(|s| s["predicateType"] == statement::TASK_VERIFICATION);
864 ObjectVerification {
865 object: commit.to_owned(),
866 kind: "task".to_owned(),
867 task: task.to_owned(),
868 verdict: tv.and_then(|s| s["predicate"]["verdict"].as_str().map(str::to_owned)),
869 outcome: if statements.is_empty() {
870 VerifyOutcome::Missing
871 } else {
872 VerifyOutcome::Ok
873 },
874 reasons: Vec::new(),
875 signer: None,
876 statements: statements.len(),
877 same_tree_as: None,
878 }
879}
880
881#[allow(clippy::too_many_lines)] fn run(
883 start: &Path,
884 opts: &Options,
885 only: Option<(&str, Option<&str>)>,
886) -> Result<Checked, Refusal> {
887 let top =
888 Git::at(start).toplevel().ok().flatten().ok_or_else(|| {
889 Refusal::new(CommandStatus::GitPrecondition, "not in a git repository")
890 })?;
891 let git = Git::at(&top);
892 let resolve = |rev: &str| {
893 git.line(&[
894 "rev-parse",
895 "--verify",
896 "--quiet",
897 &format!("{rev}^{{commit}}"),
898 ])
899 .ok()
900 .filter(|c| !c.is_empty())
901 .ok_or_else(|| {
902 Refusal::new(
903 CommandStatus::GitPrecondition,
904 format!("{rev} is not a commit here (fetch it first)"),
905 )
906 })
907 };
908 let head = resolve(&opts.head)?;
909 let base = match opts.base.as_deref() {
910 Some(b) => {
911 let b = resolve(b)?;
912 Some(
913 git.line(&["merge-base", &b, &head])
914 .ok()
915 .filter(|m| !m.is_empty())
916 .ok_or_else(|| {
917 Refusal::new(
918 CommandStatus::GitPrecondition,
919 format!("{b} and {head} share no history"),
920 )
921 })?,
922 )
923 }
924 None => None,
925 };
926 let (trust, trust_shown, _held) = trust(&top, opts, only.is_some())?;
927 let mode = if opts.all || base.is_none() {
928 Mode::All
929 } else {
930 Mode::Touched
931 };
932 let mut report = BranchReport {
933 head: head.clone(),
934 base: base.clone(),
935 mode,
936 features: Vec::new(),
937 ticks: Vec::new(),
938 skipped: Vec::new(),
939 totals: BranchTotals::default(),
940 conclusion: Conclusion::Neutral,
941 trust: trust_shown,
942 accepted_unsigned: !opts.require_signed,
943 require_verified: opts.require_verified,
944 trace: Vec::new(),
945 share: None,
946 open_no_claim: Vec::new(),
947 not_evaluated: vec![verify_branch::EARLIER_TASK_REGRESSIONS.to_owned()],
948 newer_fields: Vec::new(),
949 };
950 let history = history(
951 &git,
952 &head,
953 base.as_deref().filter(|_| mode == Mode::Touched),
954 )?;
955 let start_rel = relative(start, &top).unwrap_or_default();
956 let dir = super::tree::intent_tops(&top, &head)
957 .and_then(|tops| super::tree::materialize(&top, &head, Some(&tops)))
958 .map_err(Refusal::internal)?;
959 let opened = {
960 let at = dir.path().join(&start_rel);
961 let format = crate::format::at(&at);
962 format.inspect(&at, None).map(|snap| (format, snap))
963 };
964 let Some((format, snap)) = opened else {
965 let reference = base.clone().or_else(|| match &opts.allowed_signers {
969 Some(AllowedSigners::Rev { rev, .. }) => resolve(rev).ok(),
970 _ => None,
971 });
972 if let Some(r) = reference
973 && project_at(&top, &r, &start_rel)?
974 {
975 return Err(Refusal::new(
976 CommandStatus::AttestationFailed,
977 format!(
978 "{head} holds no spec project where {r} holds one: the head removes or breaks it, and its ticks cannot be checked"
979 ),
980 ));
981 }
982 report.conclusion = Conclusion::of(&[], opts.require_verified, &report.not_evaluated);
983 return Ok(Checked {
984 report,
985 repo: top,
986 prefix: as_prefix(&start_rel),
987 });
988 };
989 let project = PathBuf::from(&snap.root);
990 let prefix = as_prefix(&relative(&project, dir.path()).unwrap_or_default());
991 let mut units: Vec<&speccoder_core::snapshot::FeatureRef> = snap
992 .features
993 .iter()
994 .filter(|f| only.is_none_or(|(name, _)| f.name == name))
995 .collect();
996 if mode == Mode::Touched
997 && let Some(b) = &base
998 {
999 let paths = changed(&git, b, &head)?;
1000 let dirs: Vec<String> = units
1001 .iter()
1002 .map(|f| format!("{prefix}{}/", f.dir.trim_end_matches('/')))
1003 .collect();
1004 let policy = crate::tools::policy::Policy::new(&[], &[]).rules(format.rules());
1005 let shared = paths.iter().any(|p| {
1007 p.strip_prefix(&prefix).is_some_and(|rel| {
1008 !dirs.iter().any(|d| p.starts_with(d))
1009 && policy.write_protected(rel) == Some(crate::tools::paths::Class::Intent)
1010 })
1011 });
1012 if !shared {
1013 units = units
1014 .into_iter()
1015 .zip(&dirs)
1016 .filter(|(_, d)| paths.iter().any(|p| p.starts_with(d.as_str())))
1017 .map(|(f, _)| f)
1018 .collect();
1019 }
1020 }
1021 let skipped = skips(&git)?;
1022 let config_path = crate::verify::find_config(&project)
1023 .and_then(|d| relative(&d, dir.path()))
1024 .map_or_else(
1025 || format!("{prefix}{}", crate::verify::config::FILE),
1026 |d| format!("{}{}", as_prefix(&d), crate::verify::config::FILE),
1027 );
1028 let mut lost: Option<BTreeMap<String, String>> = None;
1029 for f in units {
1030 let mut u = unit(format, &project, &f.name, &prefix, &skipped)?;
1031 if let Some((_, Some(task))) = only {
1032 u.ticks.retain(|t| t.task == task);
1033 }
1034 report.features.push(u.name.clone());
1035 report.skipped.extend(u.skipped);
1036 let tasks_path = u.ticks.first().map(|t| t.anchor.path.clone());
1037 let unchecked = Trust::default();
1038 let cx = Context {
1039 repo: &top,
1040 feature: &u.name,
1041 tasks_path: tasks_path.as_deref(),
1042 config_path: &config_path,
1043 trust: trust.as_ref().unwrap_or(&unchecked),
1044 format: Some(format),
1045 };
1046 let checking = trust.is_some();
1047 if lost.is_none()
1050 && u.ticks.iter().any(|t| {
1051 !history
1052 .latest
1053 .contains_key(&format!("{}/{}", t.feature, t.task))
1054 })
1055 {
1056 lost = Some(unreachable_evidence(&git, &top, &head)?);
1057 }
1058 let width = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
1060 let mut reports: Vec<TickReport> = Vec::new();
1061 let mut signed: BTreeMap<String, Vec<Value>> = BTreeMap::new();
1062 for chunk in u.ticks.chunks(width.max(1)) {
1063 let done: Vec<(TickReport, Vec<Value>)> = std::thread::scope(|scope| {
1064 let handles: Vec<_> = chunk
1065 .iter()
1066 .map(|tick| {
1067 let commit = history
1068 .latest
1069 .get(&format!("{}/{}", tick.feature, tick.task))
1070 .cloned();
1071 let cx = &cx;
1072 scope.spawn(move || {
1073 let commit = commit?;
1074 let statements = crate::evidence::statements(cx.repo, &commit);
1075 if !checking {
1076 let v = as_recorded(&commit, &tick.task, &statements);
1077 let mut r = outcome_of(tick, &commit, v, &statements, false);
1078 if r.outcome == TickOutcome::Evidenced {
1079 r.detail = Some(
1080 "as recorded: signatures not checked (no trust given)"
1081 .to_owned(),
1082 );
1083 }
1084 return Some((r, statements));
1085 }
1086 let v = super::verify::object(
1087 cx,
1088 &Target {
1089 object: commit.clone(),
1090 kind: "task",
1091 task: tick.task.clone(),
1092 },
1093 );
1094 Some((
1095 outcome_of(tick, &commit, v, &statements, opts.require_signed),
1096 statements,
1097 ))
1098 })
1099 })
1100 .collect();
1101 handles
1102 .into_iter()
1103 .zip(chunk)
1104 .map(|(h, tick)| match h.join() {
1105 Ok(Some(done)) => done,
1106 Ok(None) => (
1107 without_commit(
1108 tick,
1109 lost.as_ref().and_then(|l| {
1110 l.get(&format!("{}/{}", tick.feature, tick.task))
1111 }),
1112 ),
1113 Vec::new(),
1114 ),
1115 Err(_) => {
1116 let mut r = without_commit(tick, None);
1117 r.reason = Some(Unverifiable::Malformed);
1118 r.detail = Some("the check panicked".to_owned());
1119 (r, Vec::new())
1120 }
1121 })
1122 .collect()
1123 });
1124 for (r, statements) in done {
1125 let rows: Vec<Value> = statements
1126 .iter()
1127 .filter(|s| s["predicateType"] == statement::TASK_VERIFICATION)
1128 .flat_map(|s| {
1129 s["predicate"]["trace"]
1130 .as_array()
1131 .cloned()
1132 .unwrap_or_default()
1133 })
1134 .collect();
1135 signed.insert(r.tick.task.clone(), rows);
1136 reports.push(r);
1137 }
1138 }
1139 mark_stale(&top, format, &project, &prefix, &u.dir, &head, &mut reports);
1140 let requirements = format.requirement_ids(&project, &u.dir);
1141 report.trace.extend(trace(&requirements, &reports, &signed));
1142 report.ticks.extend(reports);
1143 }
1144 for claim in &history.bug_claims {
1147 let Some(tick) = trailer_only(format, &project, &prefix, claim) else {
1148 if !report
1150 .not_evaluated
1151 .iter()
1152 .any(|n| n == verify_branch::TRAILER_ONLY_TASKS)
1153 {
1154 report
1155 .not_evaluated
1156 .push(verify_branch::TRAILER_ONLY_TASKS.to_owned());
1157 }
1158 continue;
1159 };
1160 if only.is_some_and(|(name, task)| {
1161 tick.feature != name || task.is_some_and(|t| t != tick.task)
1162 }) {
1163 continue;
1164 }
1165 let Some(commit) = history.latest.get(claim).cloned() else {
1166 continue;
1167 };
1168 let statements = crate::evidence::statements(&top, &commit);
1169 let unchecked = Trust::default();
1170 let cx = Context {
1171 repo: &top,
1172 feature: &tick.feature,
1173 tasks_path: None,
1174 config_path: &config_path,
1175 trust: trust.as_ref().unwrap_or(&unchecked),
1176 format: Some(format),
1177 };
1178 let r = if trust.is_some() {
1179 let v = super::verify::object(
1180 &cx,
1181 &Target {
1182 object: commit.clone(),
1183 kind: "task",
1184 task: tick.task.clone(),
1185 },
1186 );
1187 outcome_of(&tick, &commit, v, &statements, opts.require_signed)
1188 } else {
1189 let v = as_recorded(&commit, &tick.task, &statements);
1190 let mut r = outcome_of(&tick, &commit, v, &statements, false);
1191 if r.outcome == TickOutcome::Evidenced {
1192 r.detail = Some("as recorded: signatures not checked (no trust given)".to_owned());
1193 }
1194 r
1195 };
1196 if !report.features.contains(&tick.feature) {
1197 report.features.push(tick.feature.clone());
1198 }
1199 report.ticks.push(r);
1200 }
1201 let mut newer: BTreeSet<String> = BTreeSet::new();
1202 for t in &report.ticks {
1203 newer.extend(t.newer_fields.iter().cloned());
1204 }
1205 report.newer_fields = newer.into_iter().collect();
1206 report.totals = BranchTotals::of(&report.ticks, report.skipped.len());
1207 report.share = verify_branch::share(&report.ticks);
1208 report.conclusion = Conclusion::of(&report.ticks, opts.require_verified, &report.not_evaluated);
1209 Ok(Checked {
1210 report,
1211 repo: top,
1212 prefix,
1213 })
1214}
1215
1216#[derive(Debug, Clone, Default)]
1218pub struct Outputs {
1219 pub markdown: Option<PathBuf>,
1221 pub github_check: Option<PathBuf>,
1223 pub check_name: String,
1225 pub allow_neutral: bool,
1227 pub annotations: bool,
1230}
1231
1232#[derive(Debug)]
1234pub struct Published {
1235 pub result: CommandResult,
1237 pub workflow_commands: Vec<String>,
1239}
1240
1241#[must_use]
1244pub fn verify_and_publish(start: &Path, opts: &Options, out: &Outputs, command: &str) -> Published {
1245 let checked = match check(start, opts) {
1246 Ok(c) => c,
1247 Err(r) => {
1248 return Published {
1249 result: CommandResult::new(command, r.status, &r.message),
1250 workflow_commands: Vec::new(),
1251 };
1252 }
1253 };
1254 let tours = if out.markdown.is_some() || out.github_check.is_some() || out.annotations {
1256 crate::tour::markdown_all(&checked)
1257 } else {
1258 Vec::new()
1259 };
1260 let report = &checked.report;
1261 let mut failed = Vec::new();
1262 if let Some(path) = &out.markdown
1263 && let Err(e) = write(path, &super::check::markdown(report, &tours))
1264 {
1265 failed.push(e);
1266 }
1267 if let Some(path) = &out.github_check {
1268 let name = if out.check_name.is_empty() {
1269 super::check::NAME
1270 } else {
1271 &out.check_name
1272 };
1273 let body = super::check::request(report, &tours, name, out.allow_neutral);
1274 if let Err(e) = write(path, &format!("{body}\n")) {
1275 failed.push(e);
1276 }
1277 }
1278 let mut workflow_commands = Vec::new();
1279 if out.annotations {
1280 workflow_commands = super::check::workflow_commands(report);
1281 if let Some(summary) = std::env::var_os("GITHUB_STEP_SUMMARY") {
1282 use std::io::Write as _;
1283 let appended = std::fs::OpenOptions::new()
1284 .append(true)
1285 .create(true)
1286 .open(&summary)
1287 .and_then(|mut f| writeln!(f, "{}", super::check::markdown(report, &tours)));
1288 if let Err(e) = appended {
1289 failed.push(format!("the step summary was not written: {e}"));
1290 }
1291 }
1292 }
1293 let result = result(command, checked.report);
1294 let result = if failed.is_empty() {
1295 result
1296 } else {
1297 failed_writes(command, result, &failed)
1298 };
1299 Published {
1300 result,
1301 workflow_commands,
1302 }
1303}
1304
1305#[must_use]
1309pub fn badge(
1310 start: &Path,
1311 opts: &Options,
1312 svg: Option<&Path>,
1313 endpoint: Option<&Path>,
1314 command: &str,
1315) -> CommandResult {
1316 let opts = Options {
1317 all: true,
1318 ..opts.clone()
1319 };
1320 let checked = match check(start, &opts) {
1321 Ok(c) => c,
1322 Err(r) => return CommandResult::new(command, r.status, &r.message),
1323 };
1324 let badge = verify_branch::Badge::of(&checked.report);
1325 let mut failed = Vec::new();
1326 if let Some(path) = svg
1327 && let Err(e) = write(path, &badge.svg())
1328 {
1329 failed.push(e);
1330 }
1331 if let Some(path) = endpoint {
1332 let json = serde_json::to_string_pretty(&badge).unwrap_or_default();
1333 if let Err(e) = write(path, &format!("{json}\n")) {
1334 failed.push(e);
1335 }
1336 }
1337 let result = CommandResult::new(
1338 command,
1339 CommandStatus::Ok,
1340 &format!("evidence: {} ({})", badge.message, badge.color),
1341 )
1342 .with_data(CommandData::BranchVerify(Box::new(checked.report)));
1343 if failed.is_empty() {
1344 result
1345 } else {
1346 failed_writes(command, result, &failed)
1347 }
1348}
1349
1350fn write(path: &Path, text: &str) -> Result<(), String> {
1351 std::fs::write(path, text).map_err(|e| format!("cannot write {}: {e}", path.display()))
1352}
1353
1354fn failed_writes(command: &str, result: CommandResult, failed: &[String]) -> CommandResult {
1356 let message = format!("{}; {}", result.message(), failed.join("; "));
1357 let shown = CommandResult::new(command, CommandStatus::Error, &message);
1358 match result.data() {
1359 Some(CommandData::BranchVerify(r)) => shown.with_data(CommandData::BranchVerify(r.clone())),
1360 _ => shown,
1361 }
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366 use super::*;
1367
1368 #[test]
1369 fn a_trust_revision_needs_both_parts() {
1370 assert!(matches!(
1371 AllowedSigners::parse_rev("origin/main:.github/speccoder/allowed_signers"),
1372 Ok(AllowedSigners::Rev { rev, path }) if rev == "origin/main" && path == ".github/speccoder/allowed_signers"
1373 ));
1374 assert!(AllowedSigners::parse_rev("main").is_err());
1375 assert!(AllowedSigners::parse_rev(":x").is_err());
1376 }
1377
1378 #[test]
1379 fn the_line_of_a_requirement_is_found_trimmed() {
1380 let text = "# Spec\n\n- **FR-001**: Users MUST log in.\n";
1381 assert_eq!(
1382 line_of(text, " - **FR-001**: Users MUST log in. "),
1383 Some(3)
1384 );
1385 assert_eq!(line_of(text, "nope"), None);
1386 }
1387
1388 #[test]
1389 fn a_git_failure_is_an_error_never_an_empty_answer() {
1390 let dir = tempfile::tempdir().unwrap();
1391 let run = |args: &[&str]| {
1392 assert!(
1393 std::process::Command::new("git")
1394 .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
1395 .args(["-c", "commit.gpgsign=false"])
1396 .args(args)
1397 .current_dir(dir.path())
1398 .output()
1399 .unwrap()
1400 .status
1401 .success()
1402 );
1403 };
1404 run(&["init", "-q"]);
1405 run(&["commit", "-q", "--allow-empty", "-m", "x"]);
1406 let git = Git::at(dir.path());
1407 let Err(r) = changed(&git, "no-such-base", "HEAD") else {
1409 panic!("a failed diff was an empty one");
1410 };
1411 assert_eq!(r.status, CommandStatus::Error);
1412 assert!(history(&git, "no-such-head", None).is_err());
1413 assert!(history(&git, "HEAD", Some("no-such-base")).is_err());
1414 assert!(unreachable_evidence(&git, dir.path(), "no-such-head").is_err());
1415 assert!(changed(&git, "HEAD", "HEAD").unwrap().is_empty());
1416 }
1417
1418 #[test]
1419 fn no_trust_is_refused_even_when_unsigned_ticks_are_accepted() {
1420 let dir = tempfile::tempdir().unwrap();
1421 let opts = Options {
1422 require_signed: false,
1423 ..Options::default()
1424 };
1425 let Err(r) = trust(dir.path(), &opts, false) else {
1426 panic!("trusted nothing")
1427 };
1428 assert_eq!(r.message, NO_TRUST);
1429 }
1430}