1use std::collections::BTreeMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use anyhow::Result;
13use chrono::{Datelike, NaiveDate};
14
15use super::hasher::{hash_tree, sha256_file};
16use super::manifest::Manifest;
17use crate::risks::fold;
18use crate::risks::model::FindingRef;
19use crate::risks::store;
20
21const MANIFEST_FILE: &str = "manifest.json";
22const PREPARE_FILE: &str = "prepare.json";
23const RESULT_FILE: &str = "result.json";
24const PENDING_SENTINEL: &str = ".run-pending";
25const RISKS_DIR: &str = "risks";
26const EVENTS_FILE: &str = "events.jsonl";
27
28#[derive(Debug, Clone)]
30pub struct VerifiedRun {
31 pub control_id: String,
32 pub run_id: String,
33 pub run_dir: PathBuf,
34}
35
36#[derive(Debug, Clone, Default)]
38pub struct VerifyReport {
39 pub verified: Vec<VerifiedRun>,
40 pub failures: Vec<VerifyFailure>,
41 pub verified_risks: Vec<VerifiedRisk>,
43 pub risk_failures: Vec<RiskFailure>,
46}
47
48#[derive(Debug, Clone)]
51pub struct VerifiedRisk {
52 pub risk_id: String,
53 pub finding_refs: usize,
55}
56
57#[derive(Debug, Clone)]
59pub struct RiskFailure {
60 pub risk_id: String,
61 pub kind: RiskFailureKind,
62 pub detail: String,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum RiskFailureKind {
67 BrokenChain,
71 BadFindingRef,
74}
75
76#[derive(Debug, Clone)]
77pub struct VerifyFailure {
78 pub control_id: String,
79 pub run_id: String,
80 pub run_dir: PathBuf,
81 pub kind: FailureKind,
82 pub detail: String,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum FailureKind {
87 BadManifest,
89 ArtifactMismatch,
91 Unreadable,
96 BrokenChain,
99 MissingPrior,
102 MissingLink,
104}
105
106impl VerifyReport {
107 pub fn is_clean(&self) -> bool {
108 self.failures.is_empty() && self.risk_failures.is_empty()
109 }
110}
111
112pub fn verify(root: &Path, control_id: Option<&str>) -> Result<VerifyReport> {
115 let mut report = VerifyReport::default();
116
117 verify_risks(root, &mut report);
121
122 let evidence = root.join("evidence");
123 if !evidence.exists() {
124 return Ok(report);
125 }
126
127 let mut grouped: BTreeMap<String, Vec<(String, PathBuf)>> = BTreeMap::new();
129 for entry in walkdir::WalkDir::new(&evidence) {
130 let entry = entry?;
131 if entry.file_name() != MANIFEST_FILE {
132 continue;
133 }
134 let dir = entry.path().parent().unwrap().to_path_buf();
135 let run_id = dir
136 .file_name()
137 .and_then(|s| s.to_str())
138 .unwrap_or("")
139 .to_string();
140 let cid = dir
141 .parent()
142 .and_then(|p| p.file_name())
143 .and_then(|s| s.to_str())
144 .unwrap_or("")
145 .to_string();
146 if let Some(want) = control_id {
147 if cid != want {
148 continue;
149 }
150 }
151 grouped
152 .entry(cid)
153 .or_default()
154 .push((run_id, entry.path().to_path_buf()));
155 }
156
157 for (cid, mut runs) in grouped {
158 runs.sort_by(|a, b| a.0.cmp(&b.0));
159 let mut prior_sha: Option<String> = None;
160 let mut prior_run_id: Option<String> = None;
161 for (run_id, manifest_path) in &runs {
162 let run_dir = manifest_path.parent().unwrap().to_path_buf();
163
164 let bytes = match fs::read(manifest_path) {
166 Ok(b) => b,
167 Err(e) => {
168 report.failures.push(VerifyFailure {
169 control_id: cid.clone(),
170 run_id: run_id.clone(),
171 run_dir: run_dir.clone(),
172 kind: FailureKind::BadManifest,
173 detail: format!("read: {e}"),
174 });
175 continue;
176 }
177 };
178 let manifest: Manifest = match serde_json::from_slice(&bytes) {
179 Ok(m) => m,
180 Err(e) => {
181 report.failures.push(VerifyFailure {
182 control_id: cid.clone(),
183 run_id: run_id.clone(),
184 run_dir: run_dir.clone(),
185 kind: FailureKind::BadManifest,
186 detail: format!("parse: {e}"),
187 });
188 continue;
189 }
190 };
191
192 match recompute_and_compare(&run_dir, &manifest) {
198 Ok(mismatches) if !mismatches.is_empty() => {
199 report.failures.push(VerifyFailure {
200 control_id: cid.clone(),
201 run_id: run_id.clone(),
202 run_dir: run_dir.clone(),
203 kind: FailureKind::ArtifactMismatch,
204 detail: mismatches.join("; "),
205 });
206 }
207 Ok(_) => {}
208 Err(io_detail) => {
209 report.failures.push(VerifyFailure {
210 control_id: cid.clone(),
211 run_id: run_id.clone(),
212 run_dir: run_dir.clone(),
213 kind: FailureKind::Unreadable,
214 detail: io_detail,
215 });
216 }
217 }
218
219 match (&manifest.prior_run, &prior_sha, &prior_run_id) {
221 (None, None, _) => {}
222 (None, Some(sha), Some(pid)) => {
223 report.failures.push(VerifyFailure {
224 control_id: cid.clone(),
225 run_id: run_id.clone(),
226 run_dir: run_dir.clone(),
227 kind: FailureKind::MissingLink,
228 detail: format!(
229 "prior run `{pid}` (sha {sha:.12}…) exists but manifest has no prior_run link"
230 ),
231 });
232 }
233 (Some(link), None, _) => {
234 report.failures.push(VerifyFailure {
235 control_id: cid.clone(),
236 run_id: run_id.clone(),
237 run_dir: run_dir.clone(),
238 kind: FailureKind::MissingPrior,
239 detail: format!(
240 "manifest claims prior `{}` but no prior manifest exists",
241 link.run_id
242 ),
243 });
244 }
245 (Some(link), Some(sha), Some(pid))
246 if &link.manifest_sha256 != sha || &link.run_id != pid =>
247 {
248 report.failures.push(VerifyFailure {
249 control_id: cid.clone(),
250 run_id: run_id.clone(),
251 run_dir: run_dir.clone(),
252 kind: FailureKind::BrokenChain,
253 detail: format!(
254 "expected prior {pid} sha {sha}; got {} sha {}",
255 link.run_id, link.manifest_sha256
256 ),
257 });
258 }
259 _ => {}
260 }
261
262 match sha256_file(manifest_path) {
265 Ok(sha) => {
266 prior_sha = Some(sha);
267 prior_run_id = Some(run_id.clone());
268 report.verified.push(VerifiedRun {
269 control_id: cid.clone(),
270 run_id: run_id.clone(),
271 run_dir,
272 });
273 }
274 Err(e) => {
275 report.failures.push(VerifyFailure {
276 control_id: cid.clone(),
277 run_id: run_id.clone(),
278 run_dir: run_dir.clone(),
279 kind: FailureKind::Unreadable,
280 detail: format!("hash manifest: {e}"),
281 });
282 }
285 }
286 }
287 }
288 Ok(report)
289}
290
291fn recompute_and_compare(run_dir: &Path, manifest: &Manifest) -> Result<Vec<String>, String> {
296 let exclude = [PREPARE_FILE, RESULT_FILE, MANIFEST_FILE, PENDING_SENTINEL];
297 let on_disk = hash_tree(run_dir, &exclude).map_err(|e| format!("hash_tree: {e}"))?;
298 let mut by_path: BTreeMap<&str, &super::hasher::HashedArtifact> = BTreeMap::new();
299 for h in &on_disk {
300 by_path.insert(h.path.as_str(), h);
301 }
302
303 let mut mismatches: Vec<String> = Vec::new();
304 let claimed: Vec<&super::manifest::Artifact> = manifest
305 .artifacts
306 .iter()
307 .chain(manifest.by_system.iter().flat_map(|b| b.artifacts.iter()))
308 .collect();
309
310 for art in &claimed {
311 match by_path.remove(art.path.as_str()) {
312 None => mismatches.push(format!("{}: file missing", art.path)),
313 Some(h) => {
314 if h.sha256 != art.sha256 || h.bytes != art.bytes {
315 mismatches.push(format!(
316 "{}: hash/size mismatch (manifest={} {}b, disk={} {}b)",
317 art.path, art.sha256, art.bytes, h.sha256, h.bytes
318 ));
319 }
320 }
321 }
322 }
323 for leftover in by_path.keys() {
324 mismatches.push(format!("{leftover}: artifact on disk not in manifest"));
325 }
326 Ok(mismatches)
327}
328
329fn verify_risks(root: &Path, report: &mut VerifyReport) {
337 let risks_dir = root.join(RISKS_DIR);
338 if !risks_dir.exists() {
339 return;
340 }
341
342 let mut risk_ids: Vec<String> = Vec::new();
344 let entries = match fs::read_dir(&risks_dir) {
345 Ok(e) => e,
346 Err(e) => {
347 report.risk_failures.push(RiskFailure {
348 risk_id: RISKS_DIR.to_string(),
349 kind: RiskFailureKind::BrokenChain,
350 detail: format!("read risks dir: {e}"),
351 });
352 return;
353 }
354 };
355 for entry in entries.flatten() {
356 let Ok(ft) = entry.file_type() else { continue };
357 if !ft.is_dir() {
358 continue;
359 }
360 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
361 continue;
362 };
363 if entry.path().join(EVENTS_FILE).exists() {
364 risk_ids.push(name);
365 }
366 }
367 risk_ids.sort();
368
369 for risk_id in risk_ids {
370 let events = match store::load_events(root, &risk_id) {
374 Ok(events) => events,
375 Err(e) => {
376 report.risk_failures.push(RiskFailure {
377 risk_id: risk_id.clone(),
378 kind: RiskFailureKind::BrokenChain,
379 detail: format!("{e:#}"),
380 });
381 continue;
382 }
383 };
384
385 let state = fold::fold(&events);
388 let mut bad: Option<String> = None;
389 for fref in &state.finding_refs {
390 let run_dir = run_dir_for(root, fref);
391 if let Err(e) = store::verify_finding_ref(&run_dir, fref) {
392 bad = Some(format!("{} ({:#})", fref.fingerprint(), e));
393 break;
394 }
395 }
396 match bad {
397 Some(detail) => report.risk_failures.push(RiskFailure {
398 risk_id: risk_id.clone(),
399 kind: RiskFailureKind::BadFindingRef,
400 detail,
401 }),
402 None => report.verified_risks.push(VerifiedRisk {
403 risk_id: risk_id.clone(),
404 finding_refs: state.finding_refs.len(),
405 }),
406 }
407 }
408}
409
410fn run_dir_for(root: &Path, fref: &FindingRef) -> PathBuf {
414 let date = fref
415 .run_id
416 .get(0..10)
417 .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
418 let year = fref.run_id.get(0..4).unwrap_or("0000");
419 let quarter = date
420 .map(|d| format!("q{}", (d.month() - 1) / 3 + 1))
421 .unwrap_or_else(|| "q0".to_string());
422 root.join("evidence")
423 .join(year)
424 .join(quarter)
425 .join(&fref.control_id)
426 .join(&fref.run_id)
427}
428
429#[cfg(test)]
430mod risk_tests {
431 use super::*;
432 use crate::risks::model::{FindingRef, Severity};
433 use crate::risks::store;
434 use std::io::Write;
435
436 const CONTROL: &str = "ra-vuln-audit";
437 const RUN_ID: &str = "2026-05-25-run-001";
438
439 fn seal_manifest(root: &Path) -> String {
442 let run_dir = root
443 .join("evidence")
444 .join("2026")
445 .join("q2")
446 .join(CONTROL)
447 .join(RUN_ID);
448 fs::create_dir_all(&run_dir).unwrap();
449 let manifest = serde_json::json!({
450 "schema_version": crate::SCHEMA_VERSION,
451 "control_id": CONTROL,
452 "run_id": RUN_ID,
453 "started_at": "2026-05-25T14:00:00Z",
454 "completed_at": "2026-05-25T14:05:00Z",
455 "agent": {
456 "model": "test", "skill": "ra-vuln-audit",
457 "skill_sha256": "0", "control_sha256": "0"
458 },
459 "registry_git_sha": "deadbeef",
460 "scope_layout": "flat",
461 "resolved_scope": [],
462 "artifacts": [],
463 "status": "complete"
464 });
465 let path = run_dir.join("manifest.json");
466 let bytes = serde_json::to_vec_pretty(&manifest).unwrap();
467 fs::write(&path, &bytes).unwrap();
468 let _: Manifest = serde_json::from_slice(&bytes).unwrap();
471 sha256_file(&path).unwrap()
472 }
473
474 fn finding_ref(manifest_sha256: String) -> FindingRef {
475 FindingRef {
476 control_id: CONTROL.to_string(),
477 run_id: RUN_ID.to_string(),
478 manifest_sha256,
479 finding_id: "S032".to_string(),
480 body_path: Some("findings.md#risk-1".to_string()),
481 }
482 }
483
484 fn open_risk(root: &Path, manifest_sha256: String) -> String {
487 let due = NaiveDate::from_ymd_opt(2026, 6, 24).unwrap();
488 let out = store::open(
489 root,
490 finding_ref(manifest_sha256),
491 "S032 — pickle deserialization RCE",
492 Severity::Critical,
493 5,
494 4,
495 vec!["api".to_string()],
496 30,
497 due,
498 "jstockdi",
499 None,
500 None,
501 )
502 .expect("open risk");
503 out.risk_id
504 }
505
506 #[test]
507 fn clean_register_verifies() {
508 let tmp = tempfile::tempdir().unwrap();
509 let root = tmp.path();
510 let sha = seal_manifest(root);
511 let risk_id = open_risk(root, sha);
512
513 let report = verify(root, None).expect("verify");
514 assert!(
515 report.is_clean(),
516 "expected clean verify, got risk failures: {:?}",
517 report.risk_failures
518 );
519 assert_eq!(report.verified_risks.len(), 1);
520 assert_eq!(report.verified_risks[0].risk_id, risk_id);
521 assert_eq!(report.verified_risks[0].finding_refs, 1);
522 }
523
524 #[test]
525 fn no_register_contributes_no_risk_results() {
526 let tmp = tempfile::tempdir().unwrap();
527 let report = verify(tmp.path(), None).expect("verify");
528 assert!(report.verified_risks.is_empty());
529 assert!(report.risk_failures.is_empty());
530 }
531
532 #[test]
533 fn tampered_log_line_breaks_chain() {
534 let tmp = tempfile::tempdir().unwrap();
535 let root = tmp.path();
536 let sha = seal_manifest(root);
537 let risk_id = open_risk(root, sha);
538 store::append(
540 root,
541 &risk_id,
542 crate::risks::model::EventData::Note {
543 text: "investigating".to_string(),
544 },
545 "jstockdi",
546 None,
547 None,
548 )
549 .expect("append note");
550
551 let events_path = root.join("risks").join(&risk_id).join("events.jsonl");
556 let text = fs::read_to_string(&events_path).unwrap();
557 let tampered = text.replacen("pickle deserialization", "pickle deserialization", 1);
558 assert_ne!(text, tampered, "tamper must change the bytes");
559 fs::write(&events_path, tampered).unwrap();
560
561 let report = verify(root, None).expect("verify");
562 assert!(!report.is_clean());
563 let f = report
564 .risk_failures
565 .iter()
566 .find(|f| f.risk_id == risk_id)
567 .expect("expected a risk failure for the tampered log");
568 assert_eq!(f.kind, RiskFailureKind::BrokenChain);
569 assert!(report.verified_risks.is_empty());
570 }
571
572 #[test]
573 fn mutated_manifest_breaks_finding_ref() {
574 let tmp = tempfile::tempdir().unwrap();
575 let root = tmp.path();
576 let sha = seal_manifest(root);
577 let risk_id = open_risk(root, sha);
578
579 let manifest_path = root
582 .join("evidence/2026/q2")
583 .join(CONTROL)
584 .join(RUN_ID)
585 .join("manifest.json");
586 let mut f = std::fs::OpenOptions::new()
587 .append(true)
588 .open(&manifest_path)
589 .unwrap();
590 f.write_all(b"\n").unwrap();
591 drop(f);
592
593 let report = verify(root, None).expect("verify");
594 assert!(!report.is_clean());
595 let f = report
596 .risk_failures
597 .iter()
598 .find(|f| f.risk_id == risk_id)
599 .expect("expected a risk failure for the mutated manifest");
600 assert_eq!(f.kind, RiskFailureKind::BadFindingRef);
601 assert!(
602 f.detail.contains("ra-vuln-audit:S032"),
603 "detail: {}",
604 f.detail
605 );
606 }
607
608 #[test]
609 fn absent_manifest_breaks_finding_ref() {
610 let tmp = tempfile::tempdir().unwrap();
611 let root = tmp.path();
612 let sha = seal_manifest(root);
613 let risk_id = open_risk(root, sha);
614
615 let run_dir = root.join("evidence/2026/q2").join(CONTROL).join(RUN_ID);
617 fs::remove_dir_all(&run_dir).unwrap();
618
619 let report = verify(root, None).expect("verify");
620 assert!(!report.is_clean());
621 let f = report
622 .risk_failures
623 .iter()
624 .find(|f| f.risk_id == risk_id)
625 .expect("expected a risk failure for the absent manifest");
626 assert_eq!(f.kind, RiskFailureKind::BadFindingRef);
627 }
628}