use std::collections::HashSet;
use std::path::Path;
use evorule_reactor::{Fact, FactId, WalRecord};
use crate::error::CliError;
use crate::{fact_log, hash};
pub fn run(fact_log_path: &Path) -> Result<(), CliError> {
println!("=== Verifying hash chain: {} ===", fact_log_path.display());
println!("Algorithm: blake3 (unified with evorule-reactor WAL)");
println!();
match evorule_reactor::read_wal_with_hash(fact_log_path) {
Ok(records) => {
let facts: Vec<Fact> = records.iter().map(|r| r.fact.clone()).collect();
println!("Facts: {} (tier1 WAL format)", facts.len());
let has_hash = records.iter().any(|r| r.chain_hash.is_some());
if has_hash {
println!("[INFO] New WAL format detected (with hash fields)");
verify_hash_chain_with_stored(&records)?;
println!("[OK] Hash chain verified (content_hash + prev_hash + chain_hash)");
} else {
println!("[WARN] Old WAL format (no hash fields), only structural verification");
}
verify_and_report_structural(&facts)
}
Err(_) => {
let facts = fact_log::read_facts(fact_log_path)?;
println!("Facts: {} (CLI raw Fact JSON format)", facts.len());
println!("[WARN] Raw Fact JSON format (no hash fields), only structural verification");
verify_and_report_structural(&facts)
}
}
}
fn verify_hash_chain_with_stored(records: &[WalRecord]) -> Result<(), CliError> {
let mut prev_hash = String::from("genesis");
for (i, record) in records.iter().enumerate() {
let fact_id = record.fact.id();
if record.chain_hash.is_none() {
let content_hash = hash::fact_hash(&record.fact)
.map_err(|e| CliError::HashChain(format!("fact[{}]: hash error: {}", i, e)))?;
let combined = format!("{}{}", prev_hash, content_hash);
prev_hash = blake3::hash(combined.as_bytes()).to_hex().to_string();
continue;
}
let recomputed_content = hash::fact_hash(&record.fact)
.map_err(|e| CliError::HashChain(format!("fact[{}]: hash error: {}", i, e)))?;
if record.content_hash.as_deref() != Some(recomputed_content.as_str()) {
return Err(CliError::HashChain(format!(
"fact[{}] (id={}): content_hash mismatch (stored={}, recomputed={})",
i,
fact_id.0,
record.content_hash.as_deref().unwrap_or("none"),
recomputed_content
)));
}
if record.prev_hash.as_deref() != Some(prev_hash.as_str()) {
return Err(CliError::HashChain(format!(
"fact[{}] (id={}): prev_hash mismatch (stored={}, expected={})",
i,
fact_id.0,
record.prev_hash.as_deref().unwrap_or("none"),
prev_hash
)));
}
let combined = format!("{}{}", prev_hash, recomputed_content);
let recomputed_chain = blake3::hash(combined.as_bytes()).to_hex().to_string();
if record.chain_hash.as_deref() != Some(recomputed_chain.as_str()) {
return Err(CliError::HashChain(format!(
"fact[{}] (id={}): chain_hash mismatch (stored={}, recomputed={})",
i,
fact_id.0,
record.chain_hash.as_deref().unwrap_or("none"),
recomputed_chain
)));
}
prev_hash = recomputed_chain;
}
Ok(())
}
fn verify_and_report_structural(facts: &[Fact]) -> Result<(), CliError> {
let errors = verify_structural_invariants(facts);
if errors.is_empty() {
println!("[OK] Structural invariants verified (FactId monotonic, cause references valid)");
if facts.is_empty() {
println!(" (empty fact log)");
} else {
println!(" genesis → F1 → F2 → ... → F{} (final)", facts.len());
}
Ok(())
} else {
eprintln!("[ERROR] Structural invariant violations:");
for e in &errors {
eprintln!(" {}", e);
}
Err(CliError::HashChain(format!(
"structural violations: {}",
errors.len()
)))
}
}
fn verify_structural_invariants(facts: &[Fact]) -> Vec<String> {
let mut errors = Vec::new();
let mut seen_ids: HashSet<FactId> = HashSet::new();
let mut prev_id: Option<FactId> = None;
for (i, fact) in facts.iter().enumerate() {
let id = fact.id();
if let Some(prev) = prev_id {
if id <= prev {
errors.push(format!(
"fact[{}]: id={} not strictly greater than prev id={} (monotonicity violated)",
i, id.0, prev.0
));
}
}
let cause: Option<FactId> = match fact {
Fact::StateTransition { cause, .. } => Some(*cause),
Fact::IoRequest { cause, .. } => Some(*cause),
_ => None,
};
if let Some(c) = cause {
if !seen_ids.contains(&c) {
errors.push(format!(
"fact[{}]: id={} references cause=F{} which does not exist (cause must point to a prior fact)",
i, id.0, c.0
));
}
}
seen_ids.insert(id);
prev_id = Some(id);
}
errors
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use evorule_reactor::{Fact, FactId, IoType};
use evorule_tcb::JsonValue;
#[test]
fn test_verify_valid_chain() {
let facts = vec![
Fact::Command {
id: FactId(1),
instruction: JsonValue::object_from_pairs(&[("type", JsonValue::string("noop"))]),
},
Fact::StateTransition {
id: FactId(2),
cause: FactId(1),
new_payload: JsonValue::empty_object(),
new_queue: vec![],
},
Fact::Stable {
id: FactId(3),
final_snapshot: JsonValue::empty_object(),
},
];
let errors = verify_structural_invariants(&facts);
assert!(
errors.is_empty(),
"valid chain should have no errors: {:?}",
errors
);
}
#[test]
fn test_verify_non_monotonic_ids() {
let facts = vec![
Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
},
Fact::Stable {
id: FactId(1), final_snapshot: JsonValue::empty_object(),
},
];
let errors = verify_structural_invariants(&facts);
assert_eq!(errors.len(), 1, "should detect non-monotonic id");
assert!(errors[0].contains("monotonicity"));
}
#[test]
fn test_verify_dangling_cause() {
let facts = vec![
Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
},
Fact::StateTransition {
id: FactId(2),
cause: FactId(99), new_payload: JsonValue::empty_object(),
new_queue: vec![],
},
];
let errors = verify_structural_invariants(&facts);
assert_eq!(errors.len(), 1, "should detect dangling cause");
assert!(errors[0].contains("cause=F99"));
}
#[test]
fn test_verify_io_request_cause() {
let facts = vec![
Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
},
Fact::IoRequest {
id: FactId(2),
cause: FactId(1),
io_type: IoType::call_external(),
params: JsonValue::empty_object(),
},
];
let errors = verify_structural_invariants(&facts);
assert!(
errors.is_empty(),
"valid IoRequest cause should pass: {:?}",
errors
);
}
#[test]
fn test_verify_empty_facts() {
let errors = verify_structural_invariants(&[]);
assert!(errors.is_empty());
}
#[test]
fn test_verify_hash_chain_detects_content_tamper() {
let fact = Fact::Command {
id: FactId(1),
instruction: JsonValue::from(42i64),
};
let content_hash = hash::fact_hash(&fact).unwrap();
let prev_hash = String::from("genesis");
let combined = format!("{}{}", prev_hash, content_hash);
let chain_hash = blake3::hash(combined.as_bytes()).to_hex().to_string();
let valid_record = WalRecord {
version_before: 0,
fact: fact.clone(),
content_hash: Some(content_hash.clone()),
prev_hash: Some(prev_hash.clone()),
chain_hash: Some(chain_hash.clone()),
};
assert!(verify_hash_chain_with_stored(&[valid_record]).is_ok());
let tampered_fact = Fact::Command {
id: FactId(1),
instruction: JsonValue::from(999i64), };
let tampered_record = WalRecord {
version_before: 0,
fact: tampered_fact,
content_hash: Some(content_hash), prev_hash: Some(prev_hash),
chain_hash: Some(chain_hash),
};
let result = verify_hash_chain_with_stored(&[tampered_record]);
assert!(result.is_err(), "内容篡改应被检测到");
assert!(format!("{}", result.unwrap_err()).contains("content_hash mismatch"));
}
#[test]
fn test_verify_hash_chain_detects_broken_link() {
let fact = Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
};
let content_hash = hash::fact_hash(&fact).unwrap();
let prev_hash = String::from("genesis");
let combined = format!("{}{}", prev_hash, content_hash);
let chain_hash = blake3::hash(combined.as_bytes()).to_hex().to_string();
let broken_record = WalRecord {
version_before: 0,
fact,
content_hash: Some(content_hash),
prev_hash: Some(String::from("tampered_prev")), chain_hash: Some(chain_hash),
};
let result = verify_hash_chain_with_stored(&[broken_record]);
assert!(result.is_err(), "链断裂应被检测到");
assert!(format!("{}", result.unwrap_err()).contains("prev_hash mismatch"));
}
}