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