1use std::collections::HashSet;
22use std::path::Path;
23
24use evorule_reactor::{Fact, FactId, WalRecord};
25
26use crate::error::CliError;
27use crate::{fact_log, hash};
28
29pub fn run(fact_log_path: &Path) -> Result<(), CliError> {
35 println!("=== Verifying hash chain: {} ===", fact_log_path.display());
36 println!("Algorithm: blake3 (unified with evorule-reactor WAL)");
37 println!();
38
39 match evorule_reactor::read_wal_with_hash(fact_log_path) {
41 Ok(records) => {
42 let facts: Vec<Fact> = records.iter().map(|r| r.fact.clone()).collect();
43 println!("Facts: {} (tier1 WAL format)", facts.len());
44
45 let has_hash = records.iter().any(|r| r.chain_hash.is_some());
47
48 if has_hash {
49 println!("[INFO] New WAL format detected (with hash fields)");
51 verify_hash_chain_with_stored(&records)?;
52 println!("[OK] Hash chain verified (content_hash + prev_hash + chain_hash)");
53 } else {
54 println!("[WARN] Old WAL format (no hash fields), only structural verification");
56 }
57
58 verify_and_report_structural(&facts)
60 }
61 Err(_) => {
62 let facts = fact_log::read_facts(fact_log_path)?;
64 println!("Facts: {} (CLI raw Fact JSON format)", facts.len());
65 println!("[WARN] Raw Fact JSON format (no hash fields), only structural verification");
66
67 verify_and_report_structural(&facts)
68 }
69 }
70}
71
72fn verify_hash_chain_with_stored(records: &[WalRecord]) -> Result<(), CliError> {
81 let mut prev_hash = String::from("genesis");
82
83 for (i, record) in records.iter().enumerate() {
84 let fact_id = record.fact.id();
85
86 if record.chain_hash.is_none() {
88 let content_hash = hash::fact_hash(&record.fact)
90 .map_err(|e| CliError::HashChain(format!("fact[{}]: hash error: {}", i, e)))?;
91 let combined = format!("{}{}", prev_hash, content_hash);
92 prev_hash = blake3::hash(combined.as_bytes()).to_hex().to_string();
93 continue;
94 }
95
96 let recomputed_content = hash::fact_hash(&record.fact)
98 .map_err(|e| CliError::HashChain(format!("fact[{}]: hash error: {}", i, e)))?;
99 if record.content_hash.as_deref() != Some(recomputed_content.as_str()) {
100 return Err(CliError::HashChain(format!(
101 "fact[{}] (id={}): content_hash mismatch (stored={}, recomputed={})",
102 i,
103 fact_id.0,
104 record.content_hash.as_deref().unwrap_or("none"),
105 recomputed_content
106 )));
107 }
108
109 if record.prev_hash.as_deref() != Some(prev_hash.as_str()) {
111 return Err(CliError::HashChain(format!(
112 "fact[{}] (id={}): prev_hash mismatch (stored={}, expected={})",
113 i,
114 fact_id.0,
115 record.prev_hash.as_deref().unwrap_or("none"),
116 prev_hash
117 )));
118 }
119
120 let combined = format!("{}{}", prev_hash, recomputed_content);
122 let recomputed_chain = blake3::hash(combined.as_bytes()).to_hex().to_string();
123 if record.chain_hash.as_deref() != Some(recomputed_chain.as_str()) {
124 return Err(CliError::HashChain(format!(
125 "fact[{}] (id={}): chain_hash mismatch (stored={}, recomputed={})",
126 i,
127 fact_id.0,
128 record.chain_hash.as_deref().unwrap_or("none"),
129 recomputed_chain
130 )));
131 }
132
133 prev_hash = recomputed_chain;
134 }
135
136 Ok(())
137}
138
139fn verify_and_report_structural(facts: &[Fact]) -> Result<(), CliError> {
141 let errors = verify_structural_invariants(facts);
142 if errors.is_empty() {
143 println!("[OK] Structural invariants verified (FactId monotonic, cause references valid)");
144 if facts.is_empty() {
145 println!(" (empty fact log)");
146 } else {
147 println!(" genesis → F1 → F2 → ... → F{} (final)", facts.len());
148 }
149 Ok(())
150 } else {
151 eprintln!("[ERROR] Structural invariant violations:");
152 for e in &errors {
153 eprintln!(" {}", e);
154 }
155 Err(CliError::HashChain(format!(
156 "structural violations: {}",
157 errors.len()
158 )))
159 }
160}
161
162fn verify_structural_invariants(facts: &[Fact]) -> Vec<String> {
168 let mut errors = Vec::new();
169 let mut seen_ids: HashSet<FactId> = HashSet::new();
170 let mut prev_id: Option<FactId> = None;
171
172 for (i, fact) in facts.iter().enumerate() {
173 let id = fact.id();
174
175 if let Some(prev) = prev_id {
177 if id <= prev {
178 errors.push(format!(
179 "fact[{}]: id={} not strictly greater than prev id={} (monotonicity violated)",
180 i, id.0, prev.0
181 ));
182 }
183 }
184
185 let cause: Option<FactId> = match fact {
187 Fact::StateTransition { cause, .. } => Some(*cause),
188 Fact::IoRequest { cause, .. } => Some(*cause),
189 _ => None,
190 };
191 if let Some(c) = cause {
192 if !seen_ids.contains(&c) {
193 errors.push(format!(
194 "fact[{}]: id={} references cause=F{} which does not exist (cause must point to a prior fact)",
195 i, id.0, c.0
196 ));
197 }
198 }
199
200 seen_ids.insert(id);
201 prev_id = Some(id);
202 }
203
204 errors
205}
206
207#[cfg(test)]
208mod tests {
209 #![allow(clippy::unwrap_used)]
210 use super::*;
211 use evorule_reactor::{Fact, FactId, IoType};
212 use evorule_tcb::JsonValue;
213
214 #[test]
215 fn test_verify_valid_chain() {
216 let facts = vec![
217 Fact::Command {
218 id: FactId(1),
219 instruction: JsonValue::object_from_pairs(&[("type", JsonValue::string("noop"))]),
220 },
221 Fact::StateTransition {
222 id: FactId(2),
223 cause: FactId(1),
224 new_payload: JsonValue::empty_object(),
225 new_queue: vec![],
226 },
227 Fact::Stable {
228 id: FactId(3),
229 version: 1,
230 },
231 ];
232 let errors = verify_structural_invariants(&facts);
233 assert!(
234 errors.is_empty(),
235 "valid chain should have no errors: {:?}",
236 errors
237 );
238 }
239
240 #[test]
241 fn test_verify_non_monotonic_ids() {
242 let facts = vec![
243 Fact::Command {
244 id: FactId(1),
245 instruction: JsonValue::empty_object(),
246 },
247 Fact::Stable {
248 id: FactId(1), version: 1,
250 },
251 ];
252 let errors = verify_structural_invariants(&facts);
253 assert_eq!(errors.len(), 1, "should detect non-monotonic id");
254 assert!(errors[0].contains("monotonicity"));
255 }
256
257 #[test]
258 fn test_verify_dangling_cause() {
259 let facts = vec![
260 Fact::Command {
261 id: FactId(1),
262 instruction: JsonValue::empty_object(),
263 },
264 Fact::StateTransition {
265 id: FactId(2),
266 cause: FactId(99), new_payload: JsonValue::empty_object(),
268 new_queue: vec![],
269 },
270 ];
271 let errors = verify_structural_invariants(&facts);
272 assert_eq!(errors.len(), 1, "should detect dangling cause");
273 assert!(errors[0].contains("cause=F99"));
274 }
275
276 #[test]
277 fn test_verify_io_request_cause() {
278 let facts = vec![
279 Fact::Command {
280 id: FactId(1),
281 instruction: JsonValue::empty_object(),
282 },
283 Fact::IoRequest {
284 id: FactId(2),
285 cause: FactId(1),
286 io_type: IoType::call_external(),
287 params: JsonValue::empty_object(),
288 },
289 ];
290 let errors = verify_structural_invariants(&facts);
291 assert!(
292 errors.is_empty(),
293 "valid IoRequest cause should pass: {:?}",
294 errors
295 );
296 }
297
298 #[test]
299 fn test_verify_empty_facts() {
300 let errors = verify_structural_invariants(&[]);
301 assert!(errors.is_empty());
302 }
303
304 #[test]
306 fn test_verify_hash_chain_detects_content_tamper() {
307 let fact = Fact::Command {
309 id: FactId(1),
310 instruction: JsonValue::from(42i64),
311 };
312 let content_hash = hash::fact_hash(&fact).unwrap();
313 let prev_hash = String::from("genesis");
314 let combined = format!("{}{}", prev_hash, content_hash);
315 let chain_hash = blake3::hash(combined.as_bytes()).to_hex().to_string();
316
317 let valid_record = WalRecord {
319 version_before: 0,
320 fact: fact.clone(),
321 content_hash: Some(content_hash.clone()),
322 prev_hash: Some(prev_hash.clone()),
323 chain_hash: Some(chain_hash.clone()),
324 };
325 assert!(verify_hash_chain_with_stored(&[valid_record]).is_ok());
326
327 let tampered_fact = Fact::Command {
329 id: FactId(1),
330 instruction: JsonValue::from(999i64), };
332 let tampered_record = WalRecord {
333 version_before: 0,
334 fact: tampered_fact,
335 content_hash: Some(content_hash), prev_hash: Some(prev_hash),
337 chain_hash: Some(chain_hash),
338 };
339 let result = verify_hash_chain_with_stored(&[tampered_record]);
340 assert!(result.is_err(), "内容篡改应被检测到");
341 assert!(format!("{}", result.unwrap_err()).contains("content_hash mismatch"));
342 }
343
344 #[test]
346 fn test_verify_hash_chain_detects_broken_link() {
347 let fact = Fact::Command {
348 id: FactId(1),
349 instruction: JsonValue::empty_object(),
350 };
351 let content_hash = hash::fact_hash(&fact).unwrap();
352 let prev_hash = String::from("genesis");
353 let combined = format!("{}{}", prev_hash, content_hash);
354 let chain_hash = blake3::hash(combined.as_bytes()).to_hex().to_string();
355
356 let broken_record = WalRecord {
358 version_before: 0,
359 fact,
360 content_hash: Some(content_hash),
361 prev_hash: Some(String::from("tampered_prev")), chain_hash: Some(chain_hash),
363 };
364 let result = verify_hash_chain_with_stored(&[broken_record]);
365 assert!(result.is_err(), "链断裂应被检测到");
366 assert!(format!("{}", result.unwrap_err()).contains("prev_hash mismatch"));
367 }
368}