1use std::collections::BTreeMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use anyhow::{anyhow, Result};
13
14use super::hasher::{hash_tree, sha256_file};
15use super::manifest::Manifest;
16
17const MANIFEST_FILE: &str = "manifest.json";
18const PREPARE_FILE: &str = "prepare.json";
19const RESULT_FILE: &str = "result.json";
20const PENDING_SENTINEL: &str = ".run-pending";
21
22#[derive(Debug, Clone)]
24pub struct VerifiedRun {
25 pub control_id: String,
26 pub run_id: String,
27 pub run_dir: PathBuf,
28}
29
30#[derive(Debug, Clone, Default)]
32pub struct VerifyReport {
33 pub verified: Vec<VerifiedRun>,
34 pub failures: Vec<VerifyFailure>,
35}
36
37#[derive(Debug, Clone)]
38pub struct VerifyFailure {
39 pub control_id: String,
40 pub run_id: String,
41 pub run_dir: PathBuf,
42 pub kind: FailureKind,
43 pub detail: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum FailureKind {
48 BadManifest,
50 ArtifactMismatch,
52 Unreadable,
57 BrokenChain,
60 MissingPrior,
63 MissingLink,
65}
66
67impl VerifyReport {
68 pub fn is_clean(&self) -> bool {
69 self.failures.is_empty()
70 }
71}
72
73pub fn verify(root: &Path, control_id: Option<&str>) -> Result<VerifyReport> {
76 let mut report = VerifyReport::default();
77 let evidence = root.join("evidence");
78 if !evidence.exists() {
79 return Ok(report);
80 }
81
82 let mut grouped: BTreeMap<String, Vec<(String, PathBuf)>> = BTreeMap::new();
84 for entry in walkdir::WalkDir::new(&evidence) {
85 let entry = entry?;
86 if entry.file_name() != MANIFEST_FILE {
87 continue;
88 }
89 let dir = entry.path().parent().unwrap().to_path_buf();
90 let run_id = dir
91 .file_name()
92 .and_then(|s| s.to_str())
93 .unwrap_or("")
94 .to_string();
95 let cid = dir
96 .parent()
97 .and_then(|p| p.file_name())
98 .and_then(|s| s.to_str())
99 .unwrap_or("")
100 .to_string();
101 if let Some(want) = control_id {
102 if cid != want {
103 continue;
104 }
105 }
106 grouped
107 .entry(cid)
108 .or_default()
109 .push((run_id, entry.path().to_path_buf()));
110 }
111
112 for (cid, mut runs) in grouped {
113 runs.sort_by(|a, b| a.0.cmp(&b.0));
114 let mut prior_sha: Option<String> = None;
115 let mut prior_run_id: Option<String> = None;
116 for (run_id, manifest_path) in &runs {
117 let run_dir = manifest_path.parent().unwrap().to_path_buf();
118
119 let bytes = match fs::read(manifest_path) {
121 Ok(b) => b,
122 Err(e) => {
123 report.failures.push(VerifyFailure {
124 control_id: cid.clone(),
125 run_id: run_id.clone(),
126 run_dir: run_dir.clone(),
127 kind: FailureKind::BadManifest,
128 detail: format!("read: {e}"),
129 });
130 continue;
131 }
132 };
133 let manifest: Manifest = match serde_json::from_slice(&bytes) {
134 Ok(m) => m,
135 Err(e) => {
136 report.failures.push(VerifyFailure {
137 control_id: cid.clone(),
138 run_id: run_id.clone(),
139 run_dir: run_dir.clone(),
140 kind: FailureKind::BadManifest,
141 detail: format!("parse: {e}"),
142 });
143 continue;
144 }
145 };
146
147 match recompute_and_compare(&run_dir, &manifest) {
153 Ok(mismatches) if !mismatches.is_empty() => {
154 report.failures.push(VerifyFailure {
155 control_id: cid.clone(),
156 run_id: run_id.clone(),
157 run_dir: run_dir.clone(),
158 kind: FailureKind::ArtifactMismatch,
159 detail: mismatches.join("; "),
160 });
161 }
162 Ok(_) => {}
163 Err(io_detail) => {
164 report.failures.push(VerifyFailure {
165 control_id: cid.clone(),
166 run_id: run_id.clone(),
167 run_dir: run_dir.clone(),
168 kind: FailureKind::Unreadable,
169 detail: io_detail,
170 });
171 }
172 }
173
174 match (&manifest.prior_run, &prior_sha, &prior_run_id) {
176 (None, None, _) => {}
177 (None, Some(sha), Some(pid)) => {
178 report.failures.push(VerifyFailure {
179 control_id: cid.clone(),
180 run_id: run_id.clone(),
181 run_dir: run_dir.clone(),
182 kind: FailureKind::MissingLink,
183 detail: format!(
184 "prior run `{pid}` (sha {sha:.12}…) exists but manifest has no prior_run link"
185 ),
186 });
187 }
188 (Some(link), None, _) => {
189 report.failures.push(VerifyFailure {
190 control_id: cid.clone(),
191 run_id: run_id.clone(),
192 run_dir: run_dir.clone(),
193 kind: FailureKind::MissingPrior,
194 detail: format!(
195 "manifest claims prior `{}` but no prior manifest exists",
196 link.run_id
197 ),
198 });
199 }
200 (Some(link), Some(sha), Some(pid))
201 if &link.manifest_sha256 != sha || &link.run_id != pid =>
202 {
203 report.failures.push(VerifyFailure {
204 control_id: cid.clone(),
205 run_id: run_id.clone(),
206 run_dir: run_dir.clone(),
207 kind: FailureKind::BrokenChain,
208 detail: format!(
209 "expected prior {pid} sha {sha}; got {} sha {}",
210 link.run_id, link.manifest_sha256
211 ),
212 });
213 }
214 _ => {}
215 }
216
217 match sha256_file(manifest_path) {
220 Ok(sha) => {
221 prior_sha = Some(sha);
222 prior_run_id = Some(run_id.clone());
223 report.verified.push(VerifiedRun {
224 control_id: cid.clone(),
225 run_id: run_id.clone(),
226 run_dir,
227 });
228 }
229 Err(e) => {
230 report.failures.push(VerifyFailure {
231 control_id: cid.clone(),
232 run_id: run_id.clone(),
233 run_dir: run_dir.clone(),
234 kind: FailureKind::Unreadable,
235 detail: format!("hash manifest: {e}"),
236 });
237 }
240 }
241 }
242 }
243 Ok(report)
244}
245
246fn recompute_and_compare(run_dir: &Path, manifest: &Manifest) -> Result<Vec<String>, String> {
251 let exclude = [PREPARE_FILE, RESULT_FILE, MANIFEST_FILE, PENDING_SENTINEL];
252 let on_disk = hash_tree(run_dir, &exclude).map_err(|e| format!("hash_tree: {e}"))?;
253 let mut by_path: BTreeMap<&str, &super::hasher::HashedArtifact> = BTreeMap::new();
254 for h in &on_disk {
255 by_path.insert(h.path.as_str(), h);
256 }
257
258 let mut mismatches: Vec<String> = Vec::new();
259 let claimed: Vec<&super::manifest::Artifact> = manifest
260 .artifacts
261 .iter()
262 .chain(manifest.by_system.iter().flat_map(|b| b.artifacts.iter()))
263 .collect();
264
265 for art in &claimed {
266 match by_path.remove(art.path.as_str()) {
267 None => mismatches.push(format!("{}: file missing", art.path)),
268 Some(h) => {
269 if h.sha256 != art.sha256 || h.bytes != art.bytes {
270 mismatches.push(format!(
271 "{}: hash/size mismatch (manifest={} {}b, disk={} {}b)",
272 art.path, art.sha256, art.bytes, h.sha256, h.bytes
273 ));
274 }
275 }
276 }
277 }
278 for leftover in by_path.keys() {
279 mismatches.push(format!("{leftover}: artifact on disk not in manifest"));
280 }
281 Ok(mismatches)
282}
283
284#[allow(dead_code)]
285fn _ensure_anyhow_used() -> anyhow::Error {
286 anyhow!("placeholder")
287}