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 pub risk_warnings: Vec<RiskWarning>,
53}
54
55#[derive(Debug, Clone)]
57pub struct RiskWarning {
58 pub dir: String,
60 pub detail: String,
61}
62
63#[derive(Debug, Clone)]
66pub struct VerifiedRisk {
67 pub risk_id: String,
68 pub finding_refs: usize,
70}
71
72#[derive(Debug, Clone)]
74pub struct RiskFailure {
75 pub risk_id: String,
76 pub kind: RiskFailureKind,
77 pub detail: String,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum RiskFailureKind {
82 BrokenChain,
86 BadFindingRef,
89}
90
91#[derive(Debug, Clone)]
92pub struct VerifyFailure {
93 pub control_id: String,
94 pub run_id: String,
95 pub run_dir: PathBuf,
96 pub kind: FailureKind,
97 pub detail: String,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum FailureKind {
102 BadManifest,
104 ArtifactMismatch,
106 Unreadable,
111 BrokenChain,
114 MissingPrior,
117 MissingLink,
119}
120
121impl VerifyReport {
122 pub fn is_clean(&self) -> bool {
123 self.failures.is_empty() && self.risk_failures.is_empty()
124 }
125}
126
127pub fn verify(root: &Path, control_id: Option<&str>) -> Result<VerifyReport> {
130 let mut report = VerifyReport::default();
131
132 verify_risks(root, &mut report);
136
137 let evidence = root.join("evidence");
138 if !evidence.exists() {
139 return Ok(report);
140 }
141
142 let mut grouped: BTreeMap<String, Vec<(String, PathBuf)>> = BTreeMap::new();
144 for entry in walkdir::WalkDir::new(&evidence) {
145 let entry = entry?;
146 if entry.file_name() != MANIFEST_FILE {
147 continue;
148 }
149 let dir = entry.path().parent().unwrap().to_path_buf();
150 let run_id = dir
151 .file_name()
152 .and_then(|s| s.to_str())
153 .unwrap_or("")
154 .to_string();
155 let cid = dir
156 .parent()
157 .and_then(|p| p.file_name())
158 .and_then(|s| s.to_str())
159 .unwrap_or("")
160 .to_string();
161 if let Some(want) = control_id {
162 if cid != want {
163 continue;
164 }
165 }
166 grouped
167 .entry(cid)
168 .or_default()
169 .push((run_id, entry.path().to_path_buf()));
170 }
171
172 for (cid, mut runs) in grouped {
173 runs.sort_by(|a, b| a.0.cmp(&b.0));
174 let mut prior_sha: Option<String> = None;
175 let mut prior_run_id: Option<String> = None;
176 for (run_id, manifest_path) in &runs {
177 let run_dir = manifest_path.parent().unwrap().to_path_buf();
178
179 let bytes = match fs::read(manifest_path) {
181 Ok(b) => b,
182 Err(e) => {
183 report.failures.push(VerifyFailure {
184 control_id: cid.clone(),
185 run_id: run_id.clone(),
186 run_dir: run_dir.clone(),
187 kind: FailureKind::BadManifest,
188 detail: format!("read: {e}"),
189 });
190 continue;
191 }
192 };
193 let manifest: Manifest = match serde_json::from_slice(&bytes) {
194 Ok(m) => m,
195 Err(e) => {
196 report.failures.push(VerifyFailure {
197 control_id: cid.clone(),
198 run_id: run_id.clone(),
199 run_dir: run_dir.clone(),
200 kind: FailureKind::BadManifest,
201 detail: format!("parse: {e}"),
202 });
203 continue;
204 }
205 };
206
207 match recompute_and_compare(&run_dir, &manifest) {
213 Ok(mismatches) if !mismatches.is_empty() => {
214 report.failures.push(VerifyFailure {
215 control_id: cid.clone(),
216 run_id: run_id.clone(),
217 run_dir: run_dir.clone(),
218 kind: FailureKind::ArtifactMismatch,
219 detail: mismatches.join("; "),
220 });
221 }
222 Ok(_) => {}
223 Err(io_detail) => {
224 report.failures.push(VerifyFailure {
225 control_id: cid.clone(),
226 run_id: run_id.clone(),
227 run_dir: run_dir.clone(),
228 kind: FailureKind::Unreadable,
229 detail: io_detail,
230 });
231 }
232 }
233
234 match (&manifest.prior_run, &prior_sha, &prior_run_id) {
236 (None, None, _) => {}
237 (None, Some(sha), Some(pid)) => {
238 report.failures.push(VerifyFailure {
239 control_id: cid.clone(),
240 run_id: run_id.clone(),
241 run_dir: run_dir.clone(),
242 kind: FailureKind::MissingLink,
243 detail: format!(
244 "prior run `{pid}` (sha {sha:.12}…) exists but manifest has no prior_run link"
245 ),
246 });
247 }
248 (Some(link), None, _) => {
249 report.failures.push(VerifyFailure {
250 control_id: cid.clone(),
251 run_id: run_id.clone(),
252 run_dir: run_dir.clone(),
253 kind: FailureKind::MissingPrior,
254 detail: format!(
255 "manifest claims prior `{}` but no prior manifest exists",
256 link.run_id
257 ),
258 });
259 }
260 (Some(link), Some(sha), Some(pid))
261 if &link.manifest_sha256 != sha || &link.run_id != pid =>
262 {
263 report.failures.push(VerifyFailure {
264 control_id: cid.clone(),
265 run_id: run_id.clone(),
266 run_dir: run_dir.clone(),
267 kind: FailureKind::BrokenChain,
268 detail: format!(
269 "expected prior {pid} sha {sha}; got {} sha {}",
270 link.run_id, link.manifest_sha256
271 ),
272 });
273 }
274 _ => {}
275 }
276
277 match sha256_file(manifest_path) {
280 Ok(sha) => {
281 prior_sha = Some(sha);
282 prior_run_id = Some(run_id.clone());
283 report.verified.push(VerifiedRun {
284 control_id: cid.clone(),
285 run_id: run_id.clone(),
286 run_dir,
287 });
288 }
289 Err(e) => {
290 report.failures.push(VerifyFailure {
291 control_id: cid.clone(),
292 run_id: run_id.clone(),
293 run_dir: run_dir.clone(),
294 kind: FailureKind::Unreadable,
295 detail: format!("hash manifest: {e}"),
296 });
297 }
300 }
301 }
302 }
303 Ok(report)
304}
305
306fn recompute_and_compare(run_dir: &Path, manifest: &Manifest) -> Result<Vec<String>, String> {
311 let exclude = [PREPARE_FILE, RESULT_FILE, MANIFEST_FILE, PENDING_SENTINEL];
312 let on_disk = hash_tree(run_dir, &exclude).map_err(|e| format!("hash_tree: {e}"))?;
313 let mut by_path: BTreeMap<&str, &super::hasher::HashedArtifact> = BTreeMap::new();
314 for h in &on_disk {
315 by_path.insert(h.path.as_str(), h);
316 }
317
318 let mut mismatches: Vec<String> = Vec::new();
319 let claimed: Vec<&super::manifest::Artifact> = manifest
320 .artifacts
321 .iter()
322 .chain(manifest.by_system.iter().flat_map(|b| b.artifacts.iter()))
323 .collect();
324
325 for art in &claimed {
326 match by_path.remove(art.path.as_str()) {
327 None => mismatches.push(format!("{}: file missing", art.path)),
328 Some(h) => {
329 if h.sha256 != art.sha256 || h.bytes != art.bytes {
330 mismatches.push(format!(
331 "{}: hash/size mismatch (manifest={} {}b, disk={} {}b)",
332 art.path, art.sha256, art.bytes, h.sha256, h.bytes
333 ));
334 }
335 }
336 }
337 }
338 for leftover in by_path.keys() {
339 mismatches.push(format!("{leftover}: artifact on disk not in manifest"));
340 }
341 Ok(mismatches)
342}
343
344fn verify_risks(root: &Path, report: &mut VerifyReport) {
352 let risks_dir = root.join(RISKS_DIR);
353 if !risks_dir.exists() {
354 return;
355 }
356
357 let members = match store::risk_ids(root) {
362 Ok(ids) => ids,
363 Err(e) => {
364 report.risk_failures.push(RiskFailure {
365 risk_id: RISKS_DIR.to_string(),
366 kind: RiskFailureKind::BrokenChain,
367 detail: format!("read risks dir: {e}"),
368 });
369 return;
370 }
371 };
372 if let Ok(entries) = fs::read_dir(&risks_dir) {
373 let mut orphans: Vec<String> = Vec::new();
374 for entry in entries.flatten() {
375 let Ok(ft) = entry.file_type() else { continue };
376 if !ft.is_dir() {
377 continue;
378 }
379 let Some(name) = entry.file_name().to_str().map(str::to_string) else {
380 continue;
381 };
382 if entry.path().join(EVENTS_FILE).exists() && !members.contains(&name) {
383 orphans.push(name);
384 }
385 }
386 orphans.sort();
387 for name in orphans {
388 report.risk_warnings.push(RiskWarning {
389 dir: format!("{RISKS_DIR}/{name}"),
390 detail: "events.jsonl outside the register (dir name is not a valid R-NNNN \
391 id) — not verified, and invisible to reports and the index; rename \
392 it into the register or remove it"
393 .into(),
394 });
395 }
396 }
397
398 for risk_id in members {
399 let events = match store::load_events(root, &risk_id) {
403 Ok(events) => events,
404 Err(e) => {
405 report.risk_failures.push(RiskFailure {
406 risk_id: risk_id.clone(),
407 kind: RiskFailureKind::BrokenChain,
408 detail: format!("{e:#}"),
409 });
410 continue;
411 }
412 };
413
414 let state = fold::fold(&events);
417 let mut bad: Option<String> = None;
418 for fref in &state.finding_refs {
419 let run_dir = run_dir_for(root, fref);
420 if let Err(e) = store::verify_finding_ref(&run_dir, fref) {
421 bad = Some(format!("{} ({:#})", fref.fingerprint(), e));
422 break;
423 }
424 }
425 match bad {
426 Some(detail) => report.risk_failures.push(RiskFailure {
427 risk_id: risk_id.clone(),
428 kind: RiskFailureKind::BadFindingRef,
429 detail,
430 }),
431 None => report.verified_risks.push(VerifiedRisk {
432 risk_id: risk_id.clone(),
433 finding_refs: state.finding_refs.len(),
434 }),
435 }
436 }
437}
438
439fn run_dir_for(root: &Path, fref: &FindingRef) -> PathBuf {
443 let date = fref
444 .run_id
445 .get(0..10)
446 .and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
447 let year = fref.run_id.get(0..4).unwrap_or("0000");
448 let quarter = date
449 .map(|d| format!("q{}", (d.month() - 1) / 3 + 1))
450 .unwrap_or_else(|| "q0".to_string());
451 root.join("evidence")
452 .join(year)
453 .join(quarter)
454 .join(&fref.control_id)
455 .join(&fref.run_id)
456}
457
458#[cfg(test)]
459mod risk_tests {
460 use super::*;
461 use crate::risks::model::{FindingRef, Severity};
462 use crate::risks::store;
463 use std::io::Write;
464
465 const CONTROL: &str = "ra-vuln-audit";
466 const RUN_ID: &str = "2026-05-25-run-001";
467
468 fn seal_manifest(root: &Path) -> String {
471 let run_dir = root
472 .join("evidence")
473 .join("2026")
474 .join("q2")
475 .join(CONTROL)
476 .join(RUN_ID);
477 fs::create_dir_all(&run_dir).unwrap();
478 let manifest = serde_json::json!({
479 "schema_version": crate::SCHEMA_VERSION,
480 "control_id": CONTROL,
481 "run_id": RUN_ID,
482 "started_at": "2026-05-25T14:00:00Z",
483 "completed_at": "2026-05-25T14:05:00Z",
484 "agent": {
485 "model": "test", "skill": "ra-vuln-audit",
486 "skill_sha256": "0", "control_sha256": "0"
487 },
488 "registry_git_sha": "deadbeef",
489 "scope_layout": "flat",
490 "resolved_scope": [],
491 "artifacts": [],
492 "status": "complete"
493 });
494 let path = run_dir.join("manifest.json");
495 let bytes = serde_json::to_vec_pretty(&manifest).unwrap();
496 fs::write(&path, &bytes).unwrap();
497 let _: Manifest = serde_json::from_slice(&bytes).unwrap();
500 sha256_file(&path).unwrap()
501 }
502
503 fn finding_ref(manifest_sha256: String) -> FindingRef {
504 FindingRef {
505 control_id: CONTROL.to_string(),
506 run_id: RUN_ID.to_string(),
507 manifest_sha256,
508 finding_id: "S032".to_string(),
509 body_path: Some("findings.md#risk-1".to_string()),
510 }
511 }
512
513 fn open_risk(root: &Path, manifest_sha256: String) -> String {
516 let due = NaiveDate::from_ymd_opt(2026, 6, 24).unwrap();
517 let out = store::open(
518 root,
519 finding_ref(manifest_sha256),
520 "S032 — pickle deserialization RCE",
521 Severity::Critical,
522 5,
523 4,
524 vec!["api".to_string()],
525 30,
526 due,
527 "jstockdi",
528 None,
529 None,
530 )
531 .expect("open risk");
532 out.risk_id
533 }
534
535 #[test]
536 fn clean_register_verifies() {
537 let tmp = tempfile::tempdir().unwrap();
538 let root = tmp.path();
539 let sha = seal_manifest(root);
540 let risk_id = open_risk(root, sha);
541
542 let report = verify(root, None).expect("verify");
543 assert!(
544 report.is_clean(),
545 "expected clean verify, got risk failures: {:?}",
546 report.risk_failures
547 );
548 assert_eq!(report.verified_risks.len(), 1);
549 assert_eq!(report.verified_risks[0].risk_id, risk_id);
550 assert_eq!(report.verified_risks[0].finding_refs, 1);
551 }
552
553 #[test]
554 fn no_register_contributes_no_risk_results() {
555 let tmp = tempfile::tempdir().unwrap();
556 let report = verify(tmp.path(), None).expect("verify");
557 assert!(report.verified_risks.is_empty());
558 assert!(report.risk_failures.is_empty());
559 }
560
561 #[test]
562 fn tampered_log_line_breaks_chain() {
563 let tmp = tempfile::tempdir().unwrap();
564 let root = tmp.path();
565 let sha = seal_manifest(root);
566 let risk_id = open_risk(root, sha);
567 store::append(
569 root,
570 &risk_id,
571 crate::risks::model::EventData::Note {
572 text: "investigating".to_string(),
573 },
574 "jstockdi",
575 None,
576 None,
577 )
578 .expect("append note");
579
580 let events_path = root.join("risks").join(&risk_id).join("events.jsonl");
585 let text = fs::read_to_string(&events_path).unwrap();
586 let tampered = text.replacen("pickle deserialization", "pickle deserialization", 1);
587 assert_ne!(text, tampered, "tamper must change the bytes");
588 fs::write(&events_path, tampered).unwrap();
589
590 let report = verify(root, None).expect("verify");
591 assert!(!report.is_clean());
592 let f = report
593 .risk_failures
594 .iter()
595 .find(|f| f.risk_id == risk_id)
596 .expect("expected a risk failure for the tampered log");
597 assert_eq!(f.kind, RiskFailureKind::BrokenChain);
598 assert!(report.verified_risks.is_empty());
599 }
600
601 #[test]
602 fn mutated_manifest_breaks_finding_ref() {
603 let tmp = tempfile::tempdir().unwrap();
604 let root = tmp.path();
605 let sha = seal_manifest(root);
606 let risk_id = open_risk(root, sha);
607
608 let manifest_path = root
611 .join("evidence/2026/q2")
612 .join(CONTROL)
613 .join(RUN_ID)
614 .join("manifest.json");
615 let mut f = std::fs::OpenOptions::new()
616 .append(true)
617 .open(&manifest_path)
618 .unwrap();
619 f.write_all(b"\n").unwrap();
620 drop(f);
621
622 let report = verify(root, None).expect("verify");
623 assert!(!report.is_clean());
624 let f = report
625 .risk_failures
626 .iter()
627 .find(|f| f.risk_id == risk_id)
628 .expect("expected a risk failure for the mutated manifest");
629 assert_eq!(f.kind, RiskFailureKind::BadFindingRef);
630 assert!(
631 f.detail.contains("ra-vuln-audit:S032"),
632 "detail: {}",
633 f.detail
634 );
635 }
636
637 #[test]
638 fn absent_manifest_breaks_finding_ref() {
639 let tmp = tempfile::tempdir().unwrap();
640 let root = tmp.path();
641 let sha = seal_manifest(root);
642 let risk_id = open_risk(root, sha);
643
644 let run_dir = root.join("evidence/2026/q2").join(CONTROL).join(RUN_ID);
646 fs::remove_dir_all(&run_dir).unwrap();
647
648 let report = verify(root, None).expect("verify");
649 assert!(!report.is_clean());
650 let f = report
651 .risk_failures
652 .iter()
653 .find(|f| f.risk_id == risk_id)
654 .expect("expected a risk failure for the absent manifest");
655 assert_eq!(f.kind, RiskFailureKind::BadFindingRef);
656 }
657}