Skip to main content

evorule_cli/commands/
verify_chain.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 EvoRule Project
3// This file is part of EvoRule, licensed under GNU Affero General Public License v3 or later.
4//! `evorule verify-chain` —— 验证 fact log 完整性(哈希链 + 结构不变量)
5//!
6//! # 三层验证
7//! 1. **哈希链**(新格式 WAL):验证 content_hash + prev_hash 链接 + chain_hash
8//! 2. **FactId 单调递增**:每个 Fact 的 id 必须严格大于前一个
9//! 3. **cause 引用有效性**:`StateTransition.cause` 和 `IoRequest.cause` 必须指向已存在的 FactId
10//!
11//! # 支持的 WAL 格式(两套 WAL 合并后统一)
12//! - **tier1 WAL 新格式**(含 `content_hash`/`prev_hash`/`chain_hash`):完整哈希链验证
13//! - **tier1 WAL 旧格式**(含 `version_before`/`fact`,无哈希字段):仅结构校验
14//! - **CLI 原始格式**(每行一个 Fact JSON,无 `version_before` 包装):仅结构校验
15//!
16//! # 为什么需要结构校验
17//! 哈希链验证检测 Fact 内容篡改和链断裂。
18//! 结构校验补充检测 fact log 内部的结构篡改(如改 id、改 cause),
19//! 即使在无哈希字段的旧格式下也能提供基本完整性保证。
20
21use 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
29/// 执行 verify-chain 子命令
30///
31/// # 退出码
32/// - 0:哈希链 + 结构不变量全部通过
33/// - 1:任一检查失败(fact 被篡改或结构异常)
34pub 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    // 尝试以 tier1 WAL 格式读取(含哈希字段)
40    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            // 检查是否有哈希字段
46            let has_hash = records.iter().any(|r| r.chain_hash.is_some());
47
48            if has_hash {
49                // 新格式:完整哈希链验证
50                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                // 旧格式:仅结构校验
55                println!("[WARN] Old WAL format (no hash fields), only structural verification");
56            }
57
58            // 结构不变量验证
59            verify_and_report_structural(&facts)
60        }
61        Err(_) => {
62            // tier1 WAL 格式读取失败,尝试 CLI 原始格式(每行一个 Fact JSON)
63            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
72/// 验证存储的哈希链完整性(新格式 WAL)
73///
74/// 逐一校验每条记录的 content_hash、prev_hash 链接、chain_hash。
75///
76/// # 验证逻辑
77/// 1. **content_hash**:重算 `fact_hash(fact)`,与存储的 `content_hash` 比对
78/// 2. **prev_hash**:存储的 `prev_hash` 应等于前一条的 `chain_hash`(首条为 `"genesis"`)
79/// 3. **chain_hash**:重算 `blake3(prev_hash + content_hash)`,与存储的 `chain_hash` 比对
80fn 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        // 跳过无哈希字段的记录(混合格式场景)
87        if record.chain_hash.is_none() {
88            // 旧格式记录,重新计算链哈希以继续
89            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        // 1. 验证 content_hash
97        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        // 2. 验证 prev_hash 链接
110        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        // 3. 验证 chain_hash
121        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
139/// 验证结构不变量并输出结果
140fn 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
162/// 验证 fact log 的结构不变量
163///
164/// # 检查项
165/// 1. FactId 严格单调递增(每个 id > 前一个 id)
166/// 2. cause 引用必须指向已出现的 FactId(StateTransition.cause / IoRequest.cause)
167fn 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        // 1. FactId 单调递增
176        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        // 2. cause 引用有效性
186        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), // same id, not strictly greater
249                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), // dangling reference
267                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    /// 验证新格式 WAL 的哈希链验证能检测内容篡改
305    #[test]
306    fn test_verify_hash_chain_detects_content_tamper() {
307        // 构造带哈希的 WalRecord
308        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        // 正常记录应通过验证
318        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        // 篡改 Fact 内容(id 不变但 instruction 变了)
328        let tampered_fact = Fact::Command {
329            id: FactId(1),
330            instruction: JsonValue::from(999i64), // 42 → 999
331        };
332        let tampered_record = WalRecord {
333            version_before: 0,
334            fact: tampered_fact,
335            content_hash: Some(content_hash), // 旧的 content_hash
336            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    /// 验证新格式 WAL 的哈希链验证能检测链断裂
345    #[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        // 篡改 prev_hash(不是 genesis)
357        let broken_record = WalRecord {
358            version_before: 0,
359            fact,
360            content_hash: Some(content_hash),
361            prev_hash: Some(String::from("tampered_prev")), // 错误的 prev_hash
362            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}