1pub mod branch;
11pub mod check;
12pub mod dsse;
13pub mod notes;
14pub mod policy;
15pub mod sign;
16pub mod statement;
17pub(crate) mod tree;
18pub mod verify;
19
20use std::path::Path;
21
22use serde_json::{Value, json};
23use speccoder_core::RunReport;
24use speccoder_core::predicate::{self, Decider, TaskRef, TaskVerificationV0, Unit};
25
26use self::dsse::Envelope;
27use self::statement::{Phase, PhaseResult, Subject};
28use crate::substrate::run_config::AttestConfig;
29
30#[must_use]
32pub fn outcome(signed: bool, signer: Option<&str>, reason: Option<&str>) -> Value {
33 json!({
34 "state": if signed { "signed" } else { "unsigned" },
35 "signer": signer,
36 "reason": reason,
37 })
38}
39
40#[derive(Debug)]
42pub struct Tick<'a> {
43 pub repo: &'a Path,
45 pub config: &'a str,
47 pub attest: &'a AttestConfig,
49 pub format: &'a str,
51 pub feature: &'a str,
53 pub feature_dir: &'a str,
55 pub task: &'a str,
57 pub title: &'a str,
59 pub line_hash: &'a str,
61 pub section: Option<&'a str>,
63 pub section_text: &'a str,
65 pub requirements: &'a [(String, String)],
68 pub commit: &'a str,
70 pub verdict: &'a str,
72 pub sandbox: Value,
74 pub executor: Value,
76 pub model: Value,
78 pub bug: Option<(Value, Value)>,
80}
81
82#[must_use]
84pub fn cites(title: &str, id: &str) -> bool {
85 title
86 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
87 .any(|word| word == id)
88}
89
90#[cfg(feature = "engine")]
91fn trace(t: &Tick<'_>, report: &RunReport) -> Vec<Value> {
96 let mut rows: Vec<Value> = t
97 .requirements
98 .iter()
99 .filter(|(id, _)| cites(t.title, id))
100 .map(|(id, hash)| {
101 json!({ "requirement": id, "requirement_hash": hash, "phase": "green", "result": t.verdict })
102 })
103 .collect();
104 let tests = report
105 .acceptance
106 .as_ref()
107 .and_then(|a| a["tests"].as_array())
108 .into_iter()
109 .flatten();
110 for test in tests {
111 let path = test["path"].as_str().unwrap_or_default();
112 let cited: Vec<&str> = test["scenarios"]
113 .as_array()
114 .into_iter()
115 .flatten()
116 .filter_map(Value::as_str)
117 .collect();
118 let row = |scenario: Option<&str>| {
119 json!({
120 "requirement": t.section,
121 "scenario": scenario,
122 "test": path,
123 "phase": "green",
124 "result": t.verdict,
125 })
126 };
127 if cited.is_empty() {
128 rows.push(row(None));
129 }
130 rows.extend(cited.into_iter().map(|s| row(Some(s))));
131 }
132 rows
133}
134
135#[must_use]
137pub fn now_rfc3339() -> String {
138 let secs = std::time::SystemTime::now()
139 .duration_since(std::time::UNIX_EPOCH)
140 .map_or(0, |d| d.as_secs());
141 let days = i64::try_from(secs / 86_400).unwrap_or(0);
142 let rem = secs % 86_400;
143 let z = days + 719_468;
145 let era = z.div_euclid(146_097);
146 let doe = z.rem_euclid(146_097);
147 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
148 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
149 let mp = (5 * doy + 2) / 153;
150 let day = doy - (153 * mp + 2) / 5 + 1;
151 let month = if mp < 10 { mp + 3 } else { mp - 9 };
152 let year = yoe + era * 400 + i64::from(month <= 2);
153 format!(
154 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
155 rem / 3600,
156 rem % 3600 / 60,
157 rem % 60
158 )
159}
160
161fn sha(text: &str) -> String {
162 crate::integrity::sha256_hex(text.as_bytes())
163}
164
165#[must_use]
167pub fn phases(report: &RunReport) -> Vec<PhaseResult> {
168 let mut out = Vec::new();
169 let checks_config = report
170 .verification
171 .as_ref()
172 .map(|v| {
173 v.checks
174 .iter()
175 .map(|c| c.argv.join(" "))
176 .collect::<Vec<_>>()
177 .join("\n")
178 })
179 .unwrap_or_default();
180 if let Some(cmp) = report
181 .verification
182 .as_ref()
183 .and_then(|v| v.comparison.as_ref())
184 {
185 out.push(PhaseResult {
186 phase: Phase::Baseline,
187 result: if cmp.pre_existing.is_empty() {
188 "PASSED"
189 } else {
190 "WARNED"
191 },
192 passed: Vec::new(),
193 warned: cmp.pre_existing.clone(),
194 failed: Vec::new(),
195 config_sha256: sha(&checks_config),
196 });
197 }
198 let acceptance = report.acceptance.as_ref();
199 let acceptance_config = acceptance
200 .map(|a| a["argv"].to_string())
201 .unwrap_or_default();
202 let acceptance_files: Vec<String> = acceptance
203 .and_then(|a| a["files"].as_array())
204 .map(|f| {
205 f.iter()
206 .filter_map(|x| x.as_str().map(str::to_owned))
207 .collect()
208 })
209 .unwrap_or_default();
210 if acceptance.is_some_and(|a| a["red"] == true) {
211 out.push(PhaseResult {
212 phase: Phase::Red,
213 result: "FAILED",
214 passed: Vec::new(),
215 warned: Vec::new(),
216 failed: acceptance_files.clone(),
217 config_sha256: sha(&acceptance_config),
218 });
219 }
220 if let Some(v) = &report.verification {
221 let mut passed = Vec::new();
222 let mut failed = Vec::new();
223 for c in &v.checks {
224 match &c.tests {
225 Some(tests) => {
226 for t in tests {
227 let id = format!("{}::{}", c.name, t.id);
228 match t.outcome {
229 speccoder_core::verify::TestOutcome::Passed => passed.push(id),
230 speccoder_core::verify::TestOutcome::Failed
231 | speccoder_core::verify::TestOutcome::Error => failed.push(id),
232 speccoder_core::verify::TestOutcome::Skipped => {}
233 }
234 }
235 }
236 None if c.status == speccoder_core::verify::CheckStatus::Passed => {
237 passed.push(c.name.clone());
238 }
239 None => failed.push(c.name.clone()),
240 }
241 }
242 passed.extend(acceptance_files);
243 out.push(PhaseResult {
244 phase: Phase::Green,
245 result: if v.passed { "PASSED" } else { "FAILED" },
246 passed,
247 warned: Vec::new(),
248 failed,
249 config_sha256: sha(&format!("{checks_config}\n{acceptance_config}")),
250 });
251 }
252 if let Some(h) = acceptance
253 .and_then(|a| a.get("held_out"))
254 .filter(|h| h["state"] == "on")
255 {
256 out.push(PhaseResult {
257 phase: Phase::HeldOut,
258 result: "PASSED",
259 passed: Vec::new(),
260 warned: Vec::new(),
261 failed: Vec::new(),
262 config_sha256: h["manifest"]
263 .as_str()
264 .unwrap_or_default()
265 .trim_start_matches("sha256:")
266 .to_owned(),
267 });
268 }
269 out
270}
271
272#[cfg(feature = "engine")]
273fn decisions(tool_calls: &[Value]) -> Vec<Value> {
275 tool_calls
276 .iter()
277 .filter(|c| c["by"] == "person" || c["by"] == "expiry")
278 .map(|c| {
279 json!({
280 "verb": if c["approved_by"] == "person" { "approve" } else if c["expired"] == true { "expired" } else { "reject" },
281 "grant": serde_json::from_value::<crate::tools::policy::Object>(c["object"].clone()).ok().map(|o| o.describe()),
282 "scope": c["scope"],
283 "reason": c["rejection"],
284 "by": c["operator"]["actor"],
285 "basis": c["operator"]["basis"],
286 "via": if c["via"].is_null() { &c["approver"]["via"] } else { &c["via"] },
288 })
289 })
290 .collect()
291}
292
293#[cfg(feature = "engine")]
294fn strength(report: &RunReport) -> Value {
298 let Some(a) = &report.strength else {
299 return Value::Null;
300 };
301 json!({
302 "visible": a.visible,
303 "combined": a.combined,
304 "held_out": a.held_out,
305 "gate": a.gate,
306 "near_misses": a.near_misses.iter().map(|n| json!({ "id": n.id, "scenario": n.scenario, "visible": n.visible, "combined": n.combined })).collect::<Vec<_>>(),
307 "permitted": a.permitted.iter().map(|p| json!({ "id": p.id, "scenario": p.scenario, "digest": p.digest, "outcome": p.outcome })).collect::<Vec<_>>(),
308 "rulings": a.rulings,
309 })
310}
311
312#[cfg(feature = "engine")]
313fn monitor(report: &RunReport) -> Value {
316 match &report.monitor {
317 None => json!({ "state": "off", "findings": [] }),
318 Some(m) if m.get("error").is_some() => json!({ "state": "unreviewed", "findings": [] }),
319 Some(m) => json!({
320 "state": if m["suspected"] == true { "suspected" } else { "clean" },
321 "findings": m.get("findings").cloned().unwrap_or_else(|| json!([])),
322 }),
323 }
324}
325
326fn resource_uri(repo: &Path, commit: &str) -> String {
328 let origin = crate::git::Git::at(repo)
329 .line(&["remote", "get-url", "origin"])
330 .ok()
331 .filter(|u| !u.is_empty());
332 match origin {
333 Some(url) => {
334 let clean = match url.split_once("://") {
336 Some((scheme, rest)) => {
337 let host_end = rest.find('/').unwrap_or(rest.len());
338 match rest[..host_end].rfind('@') {
339 Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
340 None => url.clone(),
341 }
342 }
343 None => url.clone(),
344 };
345 format!("git+{clean}@{commit}")
346 }
347 None => format!("git:{commit}"),
348 }
349}
350
351#[must_use]
354pub fn note_state(repo: &Path, object: &str) -> String {
355 use base64::Engine as _;
356 let Some(note) = notes::read(repo, object) else {
357 return "missing".to_owned();
358 };
359 let (lines, _) = dsse::decode(¬e);
360 if lines.is_empty() {
361 return "missing".to_owned();
362 }
363 let Some(sig) = lines
364 .iter()
365 .find_map(|l| l.signatures.first().map(|s| (s, l)))
366 else {
367 return "unsigned".to_owned();
368 };
369 let armored = base64::engine::general_purpose::STANDARD
370 .decode(&sig.0.sig)
371 .map(|b| String::from_utf8_lossy(&b).into_owned())
372 .unwrap_or_default();
373 let kind = if sig.1.sigstore.is_some() {
374 "sigstore"
375 } else if armored.contains("SSH SIGNATURE") {
376 "ssh"
377 } else {
378 "openpgp"
379 };
380 format!("signed {kind} {}", sig.0.keyid)
381}
382
383pub fn sign_all(lines: &mut [Envelope], signer: &sign::Signer) -> Option<String> {
386 let mut failure = None;
387 let summary_only = matches!(signer, sign::Signer::Sigstore)
390 && lines.iter().any(|l| {
391 l.statement()
392 .is_some_and(|s| s["predicateType"] == statement::VSA)
393 });
394 for line in lines.iter_mut().filter(|l| l.signatures.is_empty()) {
395 if summary_only
396 && !line
397 .statement()
398 .is_some_and(|s| s["predicateType"] == statement::VSA)
399 {
400 continue;
401 }
402 let Some(bytes) = line.signed_bytes() else {
403 continue;
404 };
405 match sign::sign(signer, &bytes) {
406 Ok((sig, material)) => {
407 line.signatures.push(sig);
408 line.sigstore = material;
409 }
410 Err(e) => {
411 failure.get_or_insert(e);
412 }
413 }
414 }
415 failure
416}
417
418fn write(repo: &Path, commit: &str, attest: &AttestConfig, mut lines: Vec<Envelope>) -> Value {
421 let (signed, signer, reason) = if attest.sign_later {
422 (
423 false,
424 None,
425 Some("[attest] sign_at = \"later\": `speccoder attest sign`".to_owned()),
426 )
427 } else {
428 match sign::signer(repo, attest) {
429 Ok(s) => match sign_all(&mut lines, &s) {
430 None => (true, Some(s.kind()), None),
431 Some(e) => (false, Some(s.kind()), Some(e)),
432 },
433 Err(e) => (false, None, Some(e)),
434 }
435 };
436 if let Err(e) = notes::write(repo, commit, &dsse::encode(&lines)) {
437 return json!({ "state": "missing", "reason": format!("writing the note failed: {e}") });
438 }
439 outcome(signed, signer, reason.as_deref())
440}
441
442#[cfg(feature = "engine")]
443fn red(report: &RunReport) -> Option<predicate::Red> {
446 let a = report.acceptance.as_ref().filter(|a| a["red"] == true)?;
447 red_of(a["red_class"].as_str()?, a["red_output"].as_str()?)
448}
449
450#[must_use]
455pub fn red_of(class: &str, output: &str) -> Option<predicate::Red> {
456 let kind = match class {
457 "assertion" => predicate::RedKind::Assertion,
458 "absent" => predicate::RedKind::MissingSymbol,
459 _ => return None,
460 };
461 let mut start = output.len().saturating_sub(predicate::RED_EXCERPT_MAX);
462 while !output.is_char_boundary(start) {
463 start += 1;
464 }
465 let excerpt = output[start..].trim_start_matches('\n');
466 let clean = statement::check_clean(&json!(excerpt)).is_ok();
467 Some(predicate::Red {
468 kind,
469 excerpt: clean.then(|| excerpt.to_owned()),
470 excerpt_sha256: clean.then(|| sha(excerpt)),
471 output_sha256: sha(output),
472 withheld: !clean,
473 })
474}
475
476fn predicate(
478 t: &Tick<'_>,
479 tree: String,
480 policy_sha256: String,
481 time: String,
482) -> TaskVerificationV0 {
483 TaskVerificationV0 {
484 revision: Some(predicate::REVISION),
485 verdict: t.verdict.to_owned(),
486 unit: Unit {
487 format: t.format.to_owned(),
488 feature: t.feature.to_owned(),
489 dir: t.feature_dir.to_owned(),
490 },
491 task: TaskRef {
492 id: t.task.to_owned(),
493 line_hash: t.line_hash.to_owned(),
494 title: t.title.to_owned(),
495 },
496 tree,
497 spec: None,
498 trace: None,
499 integrity: None,
500 monitor: None,
501 mutation: None,
502 sandbox: None,
503 executor: None,
504 model: None,
505 run: None,
506 decisions: None,
507 strength: None,
508 instructions: None,
509 decider: None,
510 policy_sha256,
511 time,
512 red: None,
513 bug: None,
514 acceptance: None,
515 context: None,
516 dependencies: None,
517 extra: std::collections::BTreeMap::new(),
518 }
519}
520
521#[must_use]
524#[cfg(feature = "engine")]
525pub fn record_tick(t: &Tick<'_>, report: &RunReport) -> Value {
526 let subject = Subject {
527 name: format!("{}/{}", t.feature, t.task),
528 commit: t.commit.to_owned(),
529 };
530 let tree = crate::git::Git::at(t.repo)
531 .line(&["rev-parse", &format!("{}^{{tree}}", t.commit)])
532 .unwrap_or_default();
533 let policy = policy::judge_config_sha256(t.config).unwrap_or_default();
534 let time = now_rfc3339();
535 let mut named: Vec<(String, Value)> = phases(report)
536 .iter()
537 .map(|p| {
538 (
539 format!("test-result/{}", p.phase.name()),
540 statement::statement(&subject, statement::TEST_RESULT, statement::test_result(p)),
541 )
542 })
543 .collect();
544 let verification = TaskVerificationV0 {
545 spec: Some(predicate::SpecRef {
546 section: t.section.map(str::to_owned),
547 section_hash: sha(t.section_text),
548 }),
549 trace: Some(json!(trace(t, report))),
550 integrity: Some(json!({ "findings": report.integrity })),
551 monitor: Some(monitor(report)),
552 mutation: Some(json!(report.mutation)),
553 sandbox: Some(t.sandbox.clone()),
554 executor: Some(t.executor.clone()),
555 model: Some(t.model.clone()),
556 run: Some(json!(report.run_id)),
557 decisions: Some(json!(decisions(&report.tool_calls))),
558 strength: Some(strength(report)),
559 instructions: Some(json!(report.instructions)),
560 red: red(report),
561 bug: t.bug.as_ref().map(|b| b.0.clone()),
562 acceptance: t.bug.as_ref().map(|b| b.1.clone()),
563 ..predicate(t, tree, policy.clone(), time.clone())
564 };
565 named.push((
566 "task-verification".to_owned(),
567 statement::statement(&subject, statement::TASK_VERIFICATION, json!(verification)),
568 ));
569 let mut lines = Vec::new();
570 let mut inputs = Vec::new();
571 for (name, s) in &named {
572 if let Err(why) = statement::check_clean(s) {
573 return json!({ "state": "missing", "reason": format!("a statement would carry {why}; none was written") });
574 }
575 let bytes = statement::canonical(s);
576 inputs.push((name.clone(), crate::integrity::sha256_hex(&bytes)));
577 lines.push(Envelope::unsigned(&bytes));
578 }
579 let summary = statement::statement(
580 &subject,
581 statement::VSA,
582 statement::vsa(&statement::Summary {
583 speccoder: env!("CARGO_PKG_VERSION").to_owned(),
584 agentplane: crate::substrate::AGENTPLANE_VERSION.to_owned(),
585 time,
586 resource_uri: resource_uri(t.repo, t.commit),
587 policy_sha256: policy,
588 inputs,
589 verdict: t.verdict.to_owned(),
590 }),
591 );
592 if let Err(why) = statement::check_clean(&summary) {
593 return json!({ "state": "missing", "reason": format!("a statement would carry {why}; none was written") });
594 }
595 lines.push(Envelope::unsigned(&statement::canonical(&summary)));
596 write(t.repo, t.commit, t.attest, lines)
597}
598
599#[must_use]
603pub fn carry(repo: &Path, from: &str, to: &str, attest: &AttestConfig) -> Value {
604 if from == to {
605 return match note_state(repo, to).split_whitespace().collect::<Vec<_>>()[..] {
607 ["signed", kind, ..] => outcome(true, Some(kind), None),
608 [state, ..] => json!({ "state": state }),
609 [] => json!({ "state": "missing" }),
610 };
611 }
612 let out = reissue(repo, from, to, attest);
613 if out["state"] != "missing" {
614 notes::remove(repo, from);
615 }
616 out
617}
618
619#[must_use]
624pub fn reissue(repo: &Path, from: &str, to: &str, attest: &AttestConfig) -> Value {
625 let Some(note) = notes::read(repo, from) else {
626 return json!({ "state": "missing", "reason": "the lane's commit had no attestation" });
627 };
628 let (envelopes, unreadable) = dsse::decode(¬e);
629 let statements: Option<Vec<Value>> = envelopes.iter().map(Envelope::statement).collect();
630 let Some(mut statements) = statements.filter(|_| unreadable == 0) else {
631 return json!({ "state": "missing", "reason": "the lane's attestation was unreadable" });
632 };
633 let tree = crate::git::Git::at(repo)
634 .line(&["rev-parse", &format!("{to}^{{tree}}")])
635 .unwrap_or_default();
636 let mut renamed = std::collections::BTreeMap::new();
637 let mut lines = Vec::new();
638 for s in statements
639 .iter_mut()
640 .filter(|s| s["predicateType"] != statement::VSA)
641 {
642 let old = crate::integrity::sha256_hex(&statement::canonical(s));
643 s["subject"][0]["digest"]["gitCommit"] = json!(to);
644 if s["predicateType"] == statement::TASK_VERIFICATION {
645 s["predicate"]["tree"] = json!(tree);
646 }
647 let bytes = statement::canonical(s);
648 renamed.insert(old, crate::integrity::sha256_hex(&bytes));
649 lines.push(Envelope::unsigned(&bytes));
650 }
651 for s in statements
652 .iter_mut()
653 .filter(|s| s["predicateType"] == statement::VSA)
654 {
655 s["subject"][0]["digest"]["gitCommit"] = json!(to);
656 s["predicate"]["resourceUri"] = json!(resource_uri(repo, to));
657 if let Some(inputs) = s["predicate"]["inputAttestations"].as_array_mut() {
658 for input in inputs {
659 let old = input["digest"]["sha256"]
660 .as_str()
661 .unwrap_or_default()
662 .to_owned();
663 if let Some(new) = renamed.get(&old) {
664 input["digest"]["sha256"] = json!(new);
665 }
666 }
667 }
668 lines.push(Envelope::unsigned(&statement::canonical(s)));
669 }
670 write(repo, to, attest, lines)
671}
672
673#[must_use]
677pub fn record_decision(t: &Tick<'_>, by: &str, reason: &str) -> Value {
678 let subject = Subject {
679 name: format!("{}/{}", t.feature, t.task),
680 commit: t.commit.to_owned(),
681 };
682 let tree = crate::git::Git::at(t.repo)
683 .line(&["rev-parse", &format!("{}^{{tree}}", t.commit)])
684 .unwrap_or_default();
685 let decision = TaskVerificationV0 {
686 decider: Some(Decider {
687 by: by.to_owned(),
688 reason: reason.to_owned(),
689 }),
690 bug: t.bug.as_ref().map(|b| b.0.clone()),
692 acceptance: t.bug.as_ref().map(|b| b.1.clone()),
693 ..predicate(
694 t,
695 tree,
696 policy::judge_config_sha256(t.config).unwrap_or_default(),
697 now_rfc3339(),
698 )
699 };
700 let s = statement::statement(&subject, statement::TASK_VERIFICATION, json!(decision));
701 if let Err(why) = statement::check_clean(&s) {
702 return json!({ "state": "missing", "reason": format!("the statement would carry {why}; none was written") });
703 }
704 write(
705 t.repo,
706 t.commit,
707 t.attest,
708 vec![Envelope::unsigned(&statement::canonical(&s))],
709 )
710}
711
712#[must_use]
715pub fn sign_at(
716 path: &Path,
717 feature: Option<&str>,
718 rev: Option<&str>,
719) -> speccoder_core::CommandResult {
720 use speccoder_core::{CommandResult, CommandStatus};
721 let inspected = crate::inspect(path, feature);
722 let Some(speccoder_core::CommandData::Inspect(snap)) = inspected.data() else {
723 return CommandResult::carried("attest", &inspected);
724 };
725 let Some(f) = snap.feature.as_ref() else {
726 return CommandResult::new(
727 "attest",
728 CommandStatus::SelectionNeeded,
729 inspected.message(),
730 );
731 };
732 let root = std::path::PathBuf::from(&snap.root);
733 let config = crate::verify::find_config(&root)
734 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
735 .unwrap_or_default();
736 let attest = match crate::substrate::run_config::parse(&config) {
737 Ok(c) => c.attest,
738 Err(e) => return CommandResult::new("attest", CommandStatus::ConfigError, &e),
739 };
740 let signer = match sign::signer(&root, &attest) {
741 Ok(s) => s,
742 Err(e) => {
743 return CommandResult::new(
744 "attest",
745 CommandStatus::ConfigError,
746 &format!("cannot sign: {e}"),
747 );
748 }
749 };
750 let targets = match verify::targets(&root, &f.feature.name, rev) {
751 Ok(t) => t,
752 Err(e) => return CommandResult::new("attest", CommandStatus::GitPrecondition, &e),
753 };
754 let (mut signed, mut objects, mut failures) = (0usize, 0usize, Vec::new());
755 for t in &targets {
756 let Some(note) = notes::read(&root, &t.object) else {
757 continue;
758 };
759 let (mut lines, unreadable) = dsse::decode(¬e);
760 let before = lines.iter().filter(|l| l.signatures.is_empty()).count();
761 if before == 0 || unreadable > 0 {
762 continue;
763 }
764 if let Some(e) = sign_all(&mut lines, &signer) {
765 failures.push(format!("{}: {e}", t.task));
766 }
767 let after = lines.iter().filter(|l| l.signatures.is_empty()).count();
768 if after < before {
769 if let Err(e) = notes::write(&root, &t.object, &dsse::encode(&lines)) {
770 failures.push(format!("{}: {e}", t.task));
771 continue;
772 }
773 signed += before - after;
774 objects += 1;
775 }
776 }
777 let mut message = format!(
778 "signed {signed} statement(s) on {objects} task object(s) with {}",
779 signer.kind()
780 );
781 if !failures.is_empty() {
782 message.push_str(&format!("; not signed: {}", failures.join("; ")));
783 }
784 CommandResult::new(
785 "attest",
786 if failures.is_empty() {
787 CommandStatus::Ok
788 } else {
789 CommandStatus::Error
790 },
791 &message,
792 )
793}
794
795#[cfg(test)]
796mod tests {
797 use super::*;
798
799 #[test]
800 fn a_red_excerpt_is_the_clean_tail_and_withheld_when_not_clean() {
801 let long = format!("{}\nassertion failed: left == right\n", "x".repeat(4000));
802 let r = red_of("assertion", &long).unwrap();
803 assert_eq!(r.kind, predicate::RedKind::Assertion);
804 let excerpt = r.excerpt.as_deref().unwrap();
805 assert!(excerpt.len() <= predicate::RED_EXCERPT_MAX);
806 assert!(excerpt.ends_with("left == right\n"));
807 assert_eq!(r.excerpt_sha256.as_deref(), Some(sha(excerpt).as_str()));
808 assert_eq!(r.output_sha256, sha(&long));
809 assert!(!r.withheld);
810
811 let dirty = red_of("absent", "error: cannot find /Users/ada/src/lib.rs").unwrap();
812 assert_eq!(dirty.kind, predicate::RedKind::MissingSymbol);
813 assert!(dirty.withheld);
814 assert_eq!(dirty.excerpt, None);
815 assert_eq!(dirty.excerpt_sha256, None);
816 assert!(red_of("flaky", "x").is_none());
817 let wide = "é".repeat(2000);
819 assert!(red_of("assertion", &wide).unwrap().excerpt.unwrap().len() <= 2048);
820 }
821
822 #[test]
823 fn the_clock_is_rfc3339_utc() {
824 let t = now_rfc3339();
825 assert_eq!(t.len(), 20, "{t}");
826 assert!(t.ends_with('Z') && t.as_bytes()[10] == b'T', "{t}");
827 assert!(t.starts_with("20"), "{t}");
828 }
829
830 #[test]
831 fn a_decision_names_who_and_through_what() {
832 let calls = vec![json!({
833 "tool": "write_file",
834 "arguments": { "path": "package.json", "content": "{}" },
835 "by": "person",
836 "approved_by": "person",
837 "scope": "once",
838 "approver": { "name": "Dana", "via": "dashboard", "basis": "asserted" },
839 "operator": { "actor": "Dana", "basis": "asserted" },
840 })];
841 let d = &decisions(&calls)[0];
842 assert_eq!(d["verb"], "approve");
843 assert_eq!(d["by"], "Dana");
844 assert_eq!(d["via"], "dashboard");
845 }
846}