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 strength(report: &RunReport) -> Value {
295 let Some(a) = &report.strength else {
296 return Value::Null;
297 };
298 json!({
299 "visible": a.visible,
300 "combined": a.combined,
301 "held_out": a.held_out,
302 "gate": a.gate,
303 "near_misses": a.near_misses.iter().map(|n| json!({ "id": n.id, "scenario": n.scenario, "visible": n.visible, "combined": n.combined })).collect::<Vec<_>>(),
304 "permitted": a.permitted.iter().map(|p| json!({ "id": p.id, "scenario": p.scenario, "digest": p.digest, "outcome": p.outcome })).collect::<Vec<_>>(),
305 "rulings": a.rulings,
306 })
307}
308
309fn monitor(report: &RunReport) -> Value {
312 match &report.monitor {
313 None => json!({ "state": "off", "findings": [] }),
314 Some(m) if m.get("error").is_some() => json!({ "state": "unreviewed", "findings": [] }),
315 Some(m) => json!({
316 "state": if m["suspected"] == true { "suspected" } else { "clean" },
317 "findings": m.get("findings").cloned().unwrap_or_else(|| json!([])),
318 }),
319 }
320}
321
322fn resource_uri(repo: &Path, commit: &str) -> String {
324 let origin = crate::git::Git::at(repo)
325 .line(&["remote", "get-url", "origin"])
326 .ok()
327 .filter(|u| !u.is_empty());
328 match origin {
329 Some(url) => {
330 let clean = match url.split_once("://") {
332 Some((scheme, rest)) => {
333 let host_end = rest.find('/').unwrap_or(rest.len());
334 match rest[..host_end].rfind('@') {
335 Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
336 None => url.clone(),
337 }
338 }
339 None => url.clone(),
340 };
341 format!("git+{clean}@{commit}")
342 }
343 None => format!("git:{commit}"),
344 }
345}
346
347#[must_use]
350pub fn note_state(repo: &Path, object: &str) -> String {
351 use base64::Engine as _;
352 let Some(note) = notes::read(repo, object) else {
353 return "missing".to_owned();
354 };
355 let (lines, _) = dsse::decode(¬e);
356 if lines.is_empty() {
357 return "missing".to_owned();
358 }
359 let Some(sig) = lines
360 .iter()
361 .find_map(|l| l.signatures.first().map(|s| (s, l)))
362 else {
363 return "unsigned".to_owned();
364 };
365 let armored = base64::engine::general_purpose::STANDARD
366 .decode(&sig.0.sig)
367 .map(|b| String::from_utf8_lossy(&b).into_owned())
368 .unwrap_or_default();
369 let kind = if sig.1.sigstore.is_some() {
370 "sigstore"
371 } else if armored.contains("SSH SIGNATURE") {
372 "ssh"
373 } else {
374 "openpgp"
375 };
376 format!("signed {kind} {}", sig.0.keyid)
377}
378
379pub fn sign_all(lines: &mut [Envelope], signer: &sign::Signer) -> Option<String> {
382 let mut failure = None;
383 let summary_only = matches!(signer, sign::Signer::Sigstore)
386 && lines.iter().any(|l| {
387 l.statement()
388 .is_some_and(|s| s["predicateType"] == statement::VSA)
389 });
390 for line in lines.iter_mut().filter(|l| l.signatures.is_empty()) {
391 if summary_only
392 && !line
393 .statement()
394 .is_some_and(|s| s["predicateType"] == statement::VSA)
395 {
396 continue;
397 }
398 let Some(bytes) = line.signed_bytes() else {
399 continue;
400 };
401 match sign::sign(signer, &bytes) {
402 Ok((sig, material)) => {
403 line.signatures.push(sig);
404 line.sigstore = material;
405 }
406 Err(e) => {
407 failure.get_or_insert(e);
408 }
409 }
410 }
411 failure
412}
413
414fn write(repo: &Path, commit: &str, attest: &AttestConfig, mut lines: Vec<Envelope>) -> Value {
417 let (signed, signer, reason) = if attest.sign_later {
418 (
419 false,
420 None,
421 Some("[attest] sign_at = \"later\": `speccoder attest sign`".to_owned()),
422 )
423 } else {
424 match sign::signer(repo, attest) {
425 Ok(s) => match sign_all(&mut lines, &s) {
426 None => (true, Some(s.kind()), None),
427 Some(e) => (false, Some(s.kind()), Some(e)),
428 },
429 Err(e) => (false, None, Some(e)),
430 }
431 };
432 if let Err(e) = notes::write(repo, commit, &dsse::encode(&lines)) {
433 return json!({ "state": "missing", "reason": format!("writing the note failed: {e}") });
434 }
435 outcome(signed, signer, reason.as_deref())
436}
437
438fn red(report: &RunReport) -> Option<predicate::Red> {
441 let a = report.acceptance.as_ref().filter(|a| a["red"] == true)?;
442 red_of(a["red_class"].as_str()?, a["red_output"].as_str()?)
443}
444
445#[must_use]
450pub fn red_of(class: &str, output: &str) -> Option<predicate::Red> {
451 let kind = match class {
452 "assertion" => predicate::RedKind::Assertion,
453 "absent" => predicate::RedKind::MissingSymbol,
454 _ => return None,
455 };
456 let mut start = output.len().saturating_sub(predicate::RED_EXCERPT_MAX);
457 while !output.is_char_boundary(start) {
458 start += 1;
459 }
460 let excerpt = output[start..].trim_start_matches('\n');
461 let clean = statement::check_clean(&json!(excerpt)).is_ok();
462 Some(predicate::Red {
463 kind,
464 excerpt: clean.then(|| excerpt.to_owned()),
465 excerpt_sha256: clean.then(|| sha(excerpt)),
466 output_sha256: sha(output),
467 withheld: !clean,
468 })
469}
470
471fn predicate(
473 t: &Tick<'_>,
474 tree: String,
475 policy_sha256: String,
476 time: String,
477) -> TaskVerificationV0 {
478 TaskVerificationV0 {
479 revision: Some(predicate::REVISION),
480 verdict: t.verdict.to_owned(),
481 unit: Unit {
482 format: t.format.to_owned(),
483 feature: t.feature.to_owned(),
484 dir: t.feature_dir.to_owned(),
485 },
486 task: TaskRef {
487 id: t.task.to_owned(),
488 line_hash: t.line_hash.to_owned(),
489 title: t.title.to_owned(),
490 },
491 tree,
492 spec: None,
493 trace: None,
494 integrity: None,
495 monitor: None,
496 mutation: None,
497 sandbox: None,
498 executor: None,
499 model: None,
500 run: None,
501 decisions: None,
502 strength: None,
503 instructions: None,
504 decider: None,
505 policy_sha256,
506 time,
507 red: None,
508 bug: None,
509 acceptance: None,
510 regression: None,
511 collection: None,
512 context: None,
513 dependencies: None,
514 extra: std::collections::BTreeMap::new(),
515 }
516}
517
518#[must_use]
521pub fn record_tick(t: &Tick<'_>, report: &RunReport) -> Value {
522 let subject = Subject {
523 name: format!("{}/{}", t.feature, t.task),
524 commit: t.commit.to_owned(),
525 };
526 let tree = crate::git::Git::at(t.repo)
527 .line(&["rev-parse", &format!("{}^{{tree}}", t.commit)])
528 .unwrap_or_default();
529 let policy = policy::judge_config_sha256(t.config).unwrap_or_default();
530 let time = now_rfc3339();
531 let mut named: Vec<(String, Value)> = phases(report)
532 .iter()
533 .map(|p| {
534 (
535 format!("test-result/{}", p.phase.name()),
536 statement::statement(&subject, statement::TEST_RESULT, statement::test_result(p)),
537 )
538 })
539 .collect();
540 let verification = TaskVerificationV0 {
541 spec: Some(predicate::SpecRef {
542 section: t.section.map(str::to_owned),
543 section_hash: sha(t.section_text),
544 }),
545 trace: Some(json!(trace(t, report))),
546 integrity: Some(json!({ "findings": report.integrity })),
547 monitor: Some(monitor(report)),
548 mutation: Some(json!(report.mutation)),
549 sandbox: Some(t.sandbox.clone()),
550 executor: Some(t.executor.clone()),
551 model: Some(t.model.clone()),
552 run: Some(json!(report.run_id)),
553 decisions: Some(json!(decisions(&report.tool_calls))),
554 strength: Some(strength(report)),
555 instructions: Some(json!(report.instructions)),
556 red: red(report),
557 bug: t.bug.as_ref().map(|b| b.0.clone()),
558 acceptance: t.bug.as_ref().map(|b| b.1.clone()).or_else(|| {
559 let a = report.acceptance.as_ref()?;
562 let files = a["files"].as_array().filter(|f| !f.is_empty())?;
563 let mut v = json!({ "source": "authored", "files": files });
564 if let Some(story) = a["story_file"].as_str() {
565 v["story_file"] = json!(story);
566 }
567 Some(v)
568 }),
569 regression: report.regression.as_ref().map(|r| r.reason.clone()),
570 context: report.context.as_ref().map(|c| json!(c)),
571 collection: report.collection.clone(),
572 ..predicate(t, tree, policy.clone(), time.clone())
573 };
574 named.push((
575 "task-verification".to_owned(),
576 statement::statement(&subject, statement::TASK_VERIFICATION, json!(verification)),
577 ));
578 let mut lines = Vec::new();
579 let mut inputs = Vec::new();
580 for (name, s) in &named {
581 if let Err(why) = statement::check_clean(s) {
582 return json!({ "state": "missing", "reason": format!("a statement would carry {why}; none was written") });
583 }
584 let bytes = statement::canonical(s);
585 inputs.push((name.clone(), crate::integrity::sha256_hex(&bytes)));
586 lines.push(Envelope::unsigned(&bytes));
587 }
588 let summary = statement::statement(
589 &subject,
590 statement::VSA,
591 statement::vsa(&statement::Summary {
592 speccoder: env!("CARGO_PKG_VERSION").to_owned(),
593 agentplane: crate::substrate::AGENTPLANE_VERSION.to_owned(),
594 time,
595 resource_uri: resource_uri(t.repo, t.commit),
596 policy_sha256: policy,
597 inputs,
598 verdict: t.verdict.to_owned(),
599 }),
600 );
601 if let Err(why) = statement::check_clean(&summary) {
602 return json!({ "state": "missing", "reason": format!("a statement would carry {why}; none was written") });
603 }
604 lines.push(Envelope::unsigned(&statement::canonical(&summary)));
605 write(t.repo, t.commit, t.attest, lines)
606}
607
608#[must_use]
612pub fn carry(repo: &Path, from: &str, to: &str, attest: &AttestConfig) -> Value {
613 if from == to {
614 return match note_state(repo, to).split_whitespace().collect::<Vec<_>>()[..] {
616 ["signed", kind, ..] => outcome(true, Some(kind), None),
617 [state, ..] => json!({ "state": state }),
618 [] => json!({ "state": "missing" }),
619 };
620 }
621 let out = reissue(repo, from, to, attest);
622 if out["state"] != "missing" {
623 notes::remove(repo, from);
624 }
625 out
626}
627
628#[must_use]
633pub fn reissue(repo: &Path, from: &str, to: &str, attest: &AttestConfig) -> Value {
634 let Some(note) = notes::read(repo, from) else {
635 return json!({ "state": "missing", "reason": "the lane's commit had no attestation" });
636 };
637 let (envelopes, unreadable) = dsse::decode(¬e);
638 let statements: Option<Vec<Value>> = envelopes.iter().map(Envelope::statement).collect();
639 let Some(mut statements) = statements.filter(|_| unreadable == 0) else {
640 return json!({ "state": "missing", "reason": "the lane's attestation was unreadable" });
641 };
642 let tree = crate::git::Git::at(repo)
643 .line(&["rev-parse", &format!("{to}^{{tree}}")])
644 .unwrap_or_default();
645 let mut renamed = std::collections::BTreeMap::new();
646 let mut lines = Vec::new();
647 for s in statements
648 .iter_mut()
649 .filter(|s| s["predicateType"] != statement::VSA)
650 {
651 let old = crate::integrity::sha256_hex(&statement::canonical(s));
652 s["subject"][0]["digest"]["gitCommit"] = json!(to);
653 if s["predicateType"] == statement::TASK_VERIFICATION {
654 s["predicate"]["tree"] = json!(tree);
655 }
656 let bytes = statement::canonical(s);
657 renamed.insert(old, crate::integrity::sha256_hex(&bytes));
658 lines.push(Envelope::unsigned(&bytes));
659 }
660 for s in statements
661 .iter_mut()
662 .filter(|s| s["predicateType"] == statement::VSA)
663 {
664 s["subject"][0]["digest"]["gitCommit"] = json!(to);
665 s["predicate"]["resourceUri"] = json!(resource_uri(repo, to));
666 if let Some(inputs) = s["predicate"]["inputAttestations"].as_array_mut() {
667 for input in inputs {
668 let old = input["digest"]["sha256"]
669 .as_str()
670 .unwrap_or_default()
671 .to_owned();
672 if let Some(new) = renamed.get(&old) {
673 input["digest"]["sha256"] = json!(new);
674 }
675 }
676 }
677 lines.push(Envelope::unsigned(&statement::canonical(s)));
678 }
679 write(repo, to, attest, lines)
680}
681
682#[must_use]
686pub fn record_decision(t: &Tick<'_>, by: &str, reason: &str) -> Value {
687 let subject = Subject {
688 name: format!("{}/{}", t.feature, t.task),
689 commit: t.commit.to_owned(),
690 };
691 let tree = crate::git::Git::at(t.repo)
692 .line(&["rev-parse", &format!("{}^{{tree}}", t.commit)])
693 .unwrap_or_default();
694 let decision = TaskVerificationV0 {
695 decider: Some(Decider {
696 by: by.to_owned(),
697 reason: reason.to_owned(),
698 }),
699 bug: t.bug.as_ref().map(|b| b.0.clone()),
701 acceptance: t.bug.as_ref().map(|b| b.1.clone()),
702 ..predicate(
703 t,
704 tree,
705 policy::judge_config_sha256(t.config).unwrap_or_default(),
706 now_rfc3339(),
707 )
708 };
709 let s = statement::statement(&subject, statement::TASK_VERIFICATION, json!(decision));
710 if let Err(why) = statement::check_clean(&s) {
711 return json!({ "state": "missing", "reason": format!("the statement would carry {why}; none was written") });
712 }
713 write(
714 t.repo,
715 t.commit,
716 t.attest,
717 vec![Envelope::unsigned(&statement::canonical(&s))],
718 )
719}
720
721#[must_use]
724pub fn sign_at(
725 path: &Path,
726 feature: Option<&str>,
727 rev: Option<&str>,
728) -> speccoder_core::CommandResult {
729 use speccoder_core::{CommandResult, CommandStatus};
730 let inspected = crate::inspect(path, feature);
731 let Some(speccoder_core::CommandData::Inspect(snap)) = inspected.data() else {
732 return CommandResult::carried("attest", &inspected);
733 };
734 let Some(f) = snap.feature.as_ref() else {
735 return CommandResult::new(
736 "attest",
737 CommandStatus::SelectionNeeded,
738 inspected.message(),
739 );
740 };
741 let root = std::path::PathBuf::from(&snap.root);
742 let config = crate::verify::find_config(&root)
743 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
744 .unwrap_or_default();
745 let attest = match crate::substrate::run_config::parse(&config) {
746 Ok(c) => c.attest,
747 Err(e) => return CommandResult::new("attest", CommandStatus::ConfigError, &e),
748 };
749 let signer = match sign::signer(&root, &attest) {
750 Ok(s) => s,
751 Err(e) => {
752 return CommandResult::new(
753 "attest",
754 CommandStatus::ConfigError,
755 &format!("cannot sign: {e}"),
756 );
757 }
758 };
759 let targets = match verify::targets(&root, &f.feature.name, rev) {
760 Ok(t) => t,
761 Err(e) => return CommandResult::new("attest", CommandStatus::GitPrecondition, &e),
762 };
763 let (mut signed, mut objects, mut failures) = (0usize, 0usize, Vec::new());
764 for t in &targets {
765 let Some(note) = notes::read(&root, &t.object) else {
766 continue;
767 };
768 let (mut lines, unreadable) = dsse::decode(¬e);
769 let before = lines.iter().filter(|l| l.signatures.is_empty()).count();
770 if before == 0 || unreadable > 0 {
771 continue;
772 }
773 if let Some(e) = sign_all(&mut lines, &signer) {
774 failures.push(format!("{}: {e}", t.task));
775 }
776 let after = lines.iter().filter(|l| l.signatures.is_empty()).count();
777 if after < before {
778 if let Err(e) = notes::write(&root, &t.object, &dsse::encode(&lines)) {
779 failures.push(format!("{}: {e}", t.task));
780 continue;
781 }
782 signed += before - after;
783 objects += 1;
784 }
785 }
786 let mut message = format!(
787 "signed {signed} statement(s) on {objects} task object(s) with {}",
788 signer.kind()
789 );
790 if !failures.is_empty() {
791 message.push_str(&format!("; not signed: {}", failures.join("; ")));
792 }
793 CommandResult::new(
794 "attest",
795 if failures.is_empty() {
796 CommandStatus::Ok
797 } else {
798 CommandStatus::Error
799 },
800 &message,
801 )
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807
808 #[test]
809 fn a_red_excerpt_is_the_clean_tail_and_withheld_when_not_clean() {
810 let long = format!("{}\nassertion failed: left == right\n", "x".repeat(4000));
811 let r = red_of("assertion", &long).unwrap();
812 assert_eq!(r.kind, predicate::RedKind::Assertion);
813 let excerpt = r.excerpt.as_deref().unwrap();
814 assert!(excerpt.len() <= predicate::RED_EXCERPT_MAX);
815 assert!(excerpt.ends_with("left == right\n"));
816 assert_eq!(r.excerpt_sha256.as_deref(), Some(sha(excerpt).as_str()));
817 assert_eq!(r.output_sha256, sha(&long));
818 assert!(!r.withheld);
819
820 let dirty = red_of("absent", "error: cannot find /Users/ada/src/lib.rs").unwrap();
821 assert_eq!(dirty.kind, predicate::RedKind::MissingSymbol);
822 assert!(dirty.withheld);
823 assert_eq!(dirty.excerpt, None);
824 assert_eq!(dirty.excerpt_sha256, None);
825 assert!(red_of("flaky", "x").is_none());
826 let wide = "é".repeat(2000);
828 assert!(red_of("assertion", &wide).unwrap().excerpt.unwrap().len() <= 2048);
829 }
830
831 #[test]
832 fn the_clock_is_rfc3339_utc() {
833 let t = now_rfc3339();
834 assert_eq!(t.len(), 20, "{t}");
835 assert!(t.ends_with('Z') && t.as_bytes()[10] == b'T', "{t}");
836 assert!(t.starts_with("20"), "{t}");
837 }
838
839 #[test]
840 fn a_decision_names_who_and_through_what() {
841 let calls = vec![json!({
842 "tool": "write_file",
843 "arguments": { "path": "package.json", "content": "{}" },
844 "by": "person",
845 "approved_by": "person",
846 "scope": "once",
847 "approver": { "name": "Dana", "via": "dashboard", "basis": "asserted" },
848 "operator": { "actor": "Dana", "basis": "asserted" },
849 })];
850 let d = &decisions(&calls)[0];
851 assert_eq!(d["verb"], "approve");
852 assert_eq!(d["by"], "Dana");
853 assert_eq!(d["via"], "dashboard");
854 }
855}