use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Result;
use chrono::{Datelike, NaiveDate};
use super::hasher::{hash_tree, sha256_file};
use super::manifest::Manifest;
use crate::risks::fold;
use crate::risks::model::FindingRef;
use crate::risks::store;
const MANIFEST_FILE: &str = "manifest.json";
const PREPARE_FILE: &str = "prepare.json";
const RESULT_FILE: &str = "result.json";
const PENDING_SENTINEL: &str = ".run-pending";
const RISKS_DIR: &str = "risks";
const EVENTS_FILE: &str = "events.jsonl";
#[derive(Debug, Clone)]
pub struct VerifiedRun {
pub control_id: String,
pub run_id: String,
pub run_dir: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct VerifyReport {
pub verified: Vec<VerifiedRun>,
pub failures: Vec<VerifyFailure>,
pub verified_risks: Vec<VerifiedRisk>,
pub risk_failures: Vec<RiskFailure>,
pub risk_warnings: Vec<RiskWarning>,
}
#[derive(Debug, Clone)]
pub struct RiskWarning {
pub dir: String,
pub detail: String,
}
#[derive(Debug, Clone)]
pub struct VerifiedRisk {
pub risk_id: String,
pub finding_refs: usize,
}
#[derive(Debug, Clone)]
pub struct RiskFailure {
pub risk_id: String,
pub kind: RiskFailureKind,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RiskFailureKind {
BrokenChain,
BadFindingRef,
}
#[derive(Debug, Clone)]
pub struct VerifyFailure {
pub control_id: String,
pub run_id: String,
pub run_dir: PathBuf,
pub kind: FailureKind,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FailureKind {
BadManifest,
ArtifactMismatch,
Unreadable,
BrokenChain,
MissingPrior,
MissingLink,
}
impl VerifyReport {
pub fn is_clean(&self) -> bool {
self.failures.is_empty() && self.risk_failures.is_empty()
}
}
pub fn verify(root: &Path, control_id: Option<&str>) -> Result<VerifyReport> {
let mut report = VerifyReport::default();
verify_risks(root, &mut report);
let evidence = root.join("evidence");
if !evidence.exists() {
return Ok(report);
}
let mut grouped: BTreeMap<String, Vec<(String, PathBuf)>> = BTreeMap::new();
for entry in walkdir::WalkDir::new(&evidence) {
let entry = entry?;
if entry.file_name() != MANIFEST_FILE {
continue;
}
let dir = entry.path().parent().unwrap().to_path_buf();
let run_id = dir
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let cid = dir
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
if let Some(want) = control_id {
if cid != want {
continue;
}
}
grouped
.entry(cid)
.or_default()
.push((run_id, entry.path().to_path_buf()));
}
for (cid, mut runs) in grouped {
runs.sort_by(|a, b| a.0.cmp(&b.0));
let mut prior_sha: Option<String> = None;
let mut prior_run_id: Option<String> = None;
for (run_id, manifest_path) in &runs {
let run_dir = manifest_path.parent().unwrap().to_path_buf();
let bytes = match fs::read(manifest_path) {
Ok(b) => b,
Err(e) => {
report.failures.push(VerifyFailure {
control_id: cid.clone(),
run_id: run_id.clone(),
run_dir: run_dir.clone(),
kind: FailureKind::BadManifest,
detail: format!("read: {e}"),
});
continue;
}
};
let manifest: Manifest = match serde_json::from_slice(&bytes) {
Ok(m) => m,
Err(e) => {
report.failures.push(VerifyFailure {
control_id: cid.clone(),
run_id: run_id.clone(),
run_dir: run_dir.clone(),
kind: FailureKind::BadManifest,
detail: format!("parse: {e}"),
});
continue;
}
};
match recompute_and_compare(&run_dir, &manifest) {
Ok(mismatches) if !mismatches.is_empty() => {
report.failures.push(VerifyFailure {
control_id: cid.clone(),
run_id: run_id.clone(),
run_dir: run_dir.clone(),
kind: FailureKind::ArtifactMismatch,
detail: mismatches.join("; "),
});
}
Ok(_) => {}
Err(io_detail) => {
report.failures.push(VerifyFailure {
control_id: cid.clone(),
run_id: run_id.clone(),
run_dir: run_dir.clone(),
kind: FailureKind::Unreadable,
detail: io_detail,
});
}
}
match (&manifest.prior_run, &prior_sha, &prior_run_id) {
(None, None, _) => {}
(None, Some(sha), Some(pid)) => {
report.failures.push(VerifyFailure {
control_id: cid.clone(),
run_id: run_id.clone(),
run_dir: run_dir.clone(),
kind: FailureKind::MissingLink,
detail: format!(
"prior run `{pid}` (sha {sha:.12}…) exists but manifest has no prior_run link"
),
});
}
(Some(link), None, _) => {
report.failures.push(VerifyFailure {
control_id: cid.clone(),
run_id: run_id.clone(),
run_dir: run_dir.clone(),
kind: FailureKind::MissingPrior,
detail: format!(
"manifest claims prior `{}` but no prior manifest exists",
link.run_id
),
});
}
(Some(link), Some(sha), Some(pid))
if &link.manifest_sha256 != sha || &link.run_id != pid =>
{
report.failures.push(VerifyFailure {
control_id: cid.clone(),
run_id: run_id.clone(),
run_dir: run_dir.clone(),
kind: FailureKind::BrokenChain,
detail: format!(
"expected prior {pid} sha {sha}; got {} sha {}",
link.run_id, link.manifest_sha256
),
});
}
_ => {}
}
match sha256_file(manifest_path) {
Ok(sha) => {
prior_sha = Some(sha);
prior_run_id = Some(run_id.clone());
report.verified.push(VerifiedRun {
control_id: cid.clone(),
run_id: run_id.clone(),
run_dir,
});
}
Err(e) => {
report.failures.push(VerifyFailure {
control_id: cid.clone(),
run_id: run_id.clone(),
run_dir: run_dir.clone(),
kind: FailureKind::Unreadable,
detail: format!("hash manifest: {e}"),
});
}
}
}
}
Ok(report)
}
fn recompute_and_compare(run_dir: &Path, manifest: &Manifest) -> Result<Vec<String>, String> {
let exclude = [PREPARE_FILE, RESULT_FILE, MANIFEST_FILE, PENDING_SENTINEL];
let on_disk = hash_tree(run_dir, &exclude).map_err(|e| format!("hash_tree: {e}"))?;
let mut by_path: BTreeMap<&str, &super::hasher::HashedArtifact> = BTreeMap::new();
for h in &on_disk {
by_path.insert(h.path.as_str(), h);
}
let mut mismatches: Vec<String> = Vec::new();
let claimed: Vec<&super::manifest::Artifact> = manifest
.artifacts
.iter()
.chain(manifest.by_system.iter().flat_map(|b| b.artifacts.iter()))
.collect();
for art in &claimed {
match by_path.remove(art.path.as_str()) {
None => mismatches.push(format!("{}: file missing", art.path)),
Some(h) => {
if h.sha256 != art.sha256 || h.bytes != art.bytes {
mismatches.push(format!(
"{}: hash/size mismatch (manifest={} {}b, disk={} {}b)",
art.path, art.sha256, art.bytes, h.sha256, h.bytes
));
}
}
}
}
for leftover in by_path.keys() {
mismatches.push(format!("{leftover}: artifact on disk not in manifest"));
}
Ok(mismatches)
}
fn verify_risks(root: &Path, report: &mut VerifyReport) {
let risks_dir = root.join(RISKS_DIR);
if !risks_dir.exists() {
return;
}
let members = match store::risk_ids(root) {
Ok(ids) => ids,
Err(e) => {
report.risk_failures.push(RiskFailure {
risk_id: RISKS_DIR.to_string(),
kind: RiskFailureKind::BrokenChain,
detail: format!("read risks dir: {e}"),
});
return;
}
};
if let Ok(entries) = fs::read_dir(&risks_dir) {
let mut orphans: Vec<String> = Vec::new();
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else { continue };
if !ft.is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
if entry.path().join(EVENTS_FILE).exists() && !members.contains(&name) {
orphans.push(name);
}
}
orphans.sort();
for name in orphans {
report.risk_warnings.push(RiskWarning {
dir: format!("{RISKS_DIR}/{name}"),
detail: "events.jsonl outside the register (dir name is not a valid R-NNNN \
id) — not verified, and invisible to reports and the index; rename \
it into the register or remove it"
.into(),
});
}
}
for risk_id in members {
let events = match store::load_events(root, &risk_id) {
Ok(events) => events,
Err(e) => {
report.risk_failures.push(RiskFailure {
risk_id: risk_id.clone(),
kind: RiskFailureKind::BrokenChain,
detail: format!("{e:#}"),
});
continue;
}
};
let state = fold::fold(&events);
let mut bad: Option<String> = None;
for fref in &state.finding_refs {
let run_dir = run_dir_for(root, fref);
if let Err(e) = store::verify_finding_ref(&run_dir, fref) {
bad = Some(format!("{} ({:#})", fref.fingerprint(), e));
break;
}
}
match bad {
Some(detail) => report.risk_failures.push(RiskFailure {
risk_id: risk_id.clone(),
kind: RiskFailureKind::BadFindingRef,
detail,
}),
None => report.verified_risks.push(VerifiedRisk {
risk_id: risk_id.clone(),
finding_refs: state.finding_refs.len(),
}),
}
}
}
fn run_dir_for(root: &Path, fref: &FindingRef) -> PathBuf {
let date = fref
.run_id
.get(0..10)
.and_then(|s| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok());
let year = fref.run_id.get(0..4).unwrap_or("0000");
let quarter = date
.map(|d| format!("q{}", (d.month() - 1) / 3 + 1))
.unwrap_or_else(|| "q0".to_string());
root.join("evidence")
.join(year)
.join(quarter)
.join(&fref.control_id)
.join(&fref.run_id)
}
#[cfg(test)]
mod risk_tests {
use super::*;
use crate::risks::model::{FindingRef, Severity};
use crate::risks::store;
use std::io::Write;
const CONTROL: &str = "ra-vuln-audit";
const RUN_ID: &str = "2026-05-25-run-001";
fn seal_manifest(root: &Path) -> String {
let run_dir = root
.join("evidence")
.join("2026")
.join("q2")
.join(CONTROL)
.join(RUN_ID);
fs::create_dir_all(&run_dir).unwrap();
let manifest = serde_json::json!({
"schema_version": crate::SCHEMA_VERSION,
"control_id": CONTROL,
"run_id": RUN_ID,
"started_at": "2026-05-25T14:00:00Z",
"completed_at": "2026-05-25T14:05:00Z",
"agent": {
"model": "test", "skill": "ra-vuln-audit",
"skill_sha256": "0", "control_sha256": "0"
},
"registry_git_sha": "deadbeef",
"scope_layout": "flat",
"resolved_scope": [],
"artifacts": [],
"status": "complete"
});
let path = run_dir.join("manifest.json");
let bytes = serde_json::to_vec_pretty(&manifest).unwrap();
fs::write(&path, &bytes).unwrap();
let _: Manifest = serde_json::from_slice(&bytes).unwrap();
sha256_file(&path).unwrap()
}
fn finding_ref(manifest_sha256: String) -> FindingRef {
FindingRef {
control_id: CONTROL.to_string(),
run_id: RUN_ID.to_string(),
manifest_sha256,
finding_id: "S032".to_string(),
body_path: Some("findings.md#risk-1".to_string()),
}
}
fn open_risk(root: &Path, manifest_sha256: String) -> String {
let due = NaiveDate::from_ymd_opt(2026, 6, 24).unwrap();
let out = store::open(
root,
finding_ref(manifest_sha256),
"S032 — pickle deserialization RCE",
Severity::Critical,
5,
4,
vec!["api".to_string()],
30,
due,
"jstockdi",
None,
None,
)
.expect("open risk");
out.risk_id
}
#[test]
fn clean_register_verifies() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let sha = seal_manifest(root);
let risk_id = open_risk(root, sha);
let report = verify(root, None).expect("verify");
assert!(
report.is_clean(),
"expected clean verify, got risk failures: {:?}",
report.risk_failures
);
assert_eq!(report.verified_risks.len(), 1);
assert_eq!(report.verified_risks[0].risk_id, risk_id);
assert_eq!(report.verified_risks[0].finding_refs, 1);
}
#[test]
fn no_register_contributes_no_risk_results() {
let tmp = tempfile::tempdir().unwrap();
let report = verify(tmp.path(), None).expect("verify");
assert!(report.verified_risks.is_empty());
assert!(report.risk_failures.is_empty());
}
#[test]
fn tampered_log_line_breaks_chain() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let sha = seal_manifest(root);
let risk_id = open_risk(root, sha);
store::append(
root,
&risk_id,
crate::risks::model::EventData::Note {
text: "investigating".to_string(),
},
"jstockdi",
None,
None,
)
.expect("append note");
let events_path = root.join("risks").join(&risk_id).join("events.jsonl");
let text = fs::read_to_string(&events_path).unwrap();
let tampered = text.replacen("pickle deserialization", "pickle deserialization", 1);
assert_ne!(text, tampered, "tamper must change the bytes");
fs::write(&events_path, tampered).unwrap();
let report = verify(root, None).expect("verify");
assert!(!report.is_clean());
let f = report
.risk_failures
.iter()
.find(|f| f.risk_id == risk_id)
.expect("expected a risk failure for the tampered log");
assert_eq!(f.kind, RiskFailureKind::BrokenChain);
assert!(report.verified_risks.is_empty());
}
#[test]
fn mutated_manifest_breaks_finding_ref() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let sha = seal_manifest(root);
let risk_id = open_risk(root, sha);
let manifest_path = root
.join("evidence/2026/q2")
.join(CONTROL)
.join(RUN_ID)
.join("manifest.json");
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&manifest_path)
.unwrap();
f.write_all(b"\n").unwrap();
drop(f);
let report = verify(root, None).expect("verify");
assert!(!report.is_clean());
let f = report
.risk_failures
.iter()
.find(|f| f.risk_id == risk_id)
.expect("expected a risk failure for the mutated manifest");
assert_eq!(f.kind, RiskFailureKind::BadFindingRef);
assert!(
f.detail.contains("ra-vuln-audit:S032"),
"detail: {}",
f.detail
);
}
#[test]
fn absent_manifest_breaks_finding_ref() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let sha = seal_manifest(root);
let risk_id = open_risk(root, sha);
let run_dir = root.join("evidence/2026/q2").join(CONTROL).join(RUN_ID);
fs::remove_dir_all(&run_dir).unwrap();
let report = verify(root, None).expect("verify");
assert!(!report.is_clean());
let f = report
.risk_failures
.iter()
.find(|f| f.risk_id == risk_id)
.expect("expected a risk failure for the absent manifest");
assert_eq!(f.kind, RiskFailureKind::BadFindingRef);
}
}