Skip to main content

evorule_reactor/
hash.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//! 审计链哈希工具(BLAKE3)
5//!
6//! 这是 evorule 审计链的核心完整性保证机制。
7//!
8//! # 设计
9//! - 使用 `blake3` crate(1.x)计算 256 位哈希
10//! - 序列化采用显式 JSON 格式:确保序列化格式的稳定性,不受 Debug 实现变更影响
11//! - 哈希以十六进制字符串形式返回
12//!
13//! # 哈希链算法
14//! - `prev_hash` 初始为 `"genesis"`
15//! - 对每个 Fact,计算 `content_hash = blake3(fact_to_stable_json(fact))`
16//! - 链哈希:`chain_hash = blake3(prev_hash + content_hash)`
17//! - 更新 `prev_hash = chain_hash`,继续处理下一个 Fact
18//!
19//! # 两套 WAL 合并
20//! 本模块是哈希算法的**单一真相源**(single source of truth)。
21//! - evorule-governance/src/hash.rs re-export 本模块
22//! - evorule-cli/src/hash.rs re-export 本模块
23//! - 通过 `test_cross_validate_with_tier2` 测试保证三方一致
24
25use std::fmt;
26
27use evorule_tcb::JsonValue;
28use tracing::{debug, trace};
29
30use crate::fact::Fact;
31
32/// 哈希计算错误类型
33///
34/// 包含错误消息,便于排查哈希计算过程中的异常。
35#[derive(Debug)]
36pub struct HashError {
37    message: String,
38}
39
40impl HashError {
41    fn new(message: impl Into<String>) -> Self {
42        Self {
43            message: message.into(),
44        }
45    }
46}
47
48impl fmt::Display for HashError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(f, "哈希计算错误: {}", self.message)
51    }
52}
53
54impl std::error::Error for HashError {}
55
56/// 将 `JsonValue` 转换为 `serde_json::Value`
57///
58/// 内部函数,用于 Fact 序列化前的类型转换。
59/// 保证转换过程确定性,不受 JsonValue 内部实现影响。
60fn tcb_to_serde(value: &JsonValue) -> serde_json::Value {
61    match value {
62        JsonValue::Null => serde_json::Value::Null,
63        JsonValue::Bool(b) => serde_json::Value::Bool(*b),
64        JsonValue::Integer(i) => serde_json::Value::Number(serde_json::Number::from(*i)),
65        JsonValue::String(s) => serde_json::Value::String(s.to_string()),
66        JsonValue::Array(arr) => serde_json::Value::Array(arr.iter().map(tcb_to_serde).collect()),
67        JsonValue::Object(obj) => {
68            let mut map = serde_json::Map::new();
69            for (k, v) in obj.iter() {
70                map.insert(k.clone(), tcb_to_serde(v));
71            }
72            serde_json::Value::Object(map)
73        }
74    }
75}
76
77/// 将 Fact 序列化为稳定的 JSON 格式
78///
79/// 使用显式的 JSON 序列化而非依赖 Debug trait,确保:
80/// 1. 序列化格式不受 Rust 版本或 Debug 实现变更影响
81/// 2. 字段顺序固定且可预测(serde_json::Map 基于 BTreeMap,字母序)
82/// 3. 跨版本兼容性有保障
83///
84/// # 参数
85/// - `fact`: 要序列化的事实
86///
87/// # 返回值
88/// - `Ok(serde_json::Value)`: 序列化后的 JSON 值
89/// - `Err(HashError)`: 序列化失败
90// 7 种 Fact 变体扁平 match + 嵌套, 拆函数需共享中间变量。详见 GATE_REFERENCE.md §六(豁免索引)
91#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
92pub fn fact_to_stable_json(fact: &Fact) -> Result<serde_json::Value, HashError> {
93    let fact_type = fact.type_name();
94    let fact_id = fact.id();
95
96    trace!(
97        事实ID = ?fact_id,
98        事实类型 = %fact_type,
99        "序列化开始"
100    );
101
102    let mut obj = serde_json::Map::new();
103    match fact {
104        Fact::Command { id, instruction } => {
105            trace!(
106                事实ID = ?id,
107                指令大小 = instruction.to_string().len(),
108                "处理命令类型事实"
109            );
110            obj.insert("type".into(), serde_json::Value::String("Command".into()));
111            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
112            obj.insert("instruction".into(), tcb_to_serde(instruction));
113        }
114        Fact::PayloadUpdate { id, path, value } => {
115            trace!(
116                事实ID = ?id,
117                路径 = %path,
118                "处理载荷更新类型事实"
119            );
120            obj.insert(
121                "type".into(),
122                serde_json::Value::String("PayloadUpdate".into()),
123            );
124            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
125            obj.insert("path".into(), serde_json::Value::String(path.clone()));
126            obj.insert("value".into(), tcb_to_serde(value));
127        }
128        Fact::StateTransition {
129            id,
130            cause,
131            new_payload,
132            new_queue,
133        } => {
134            trace!(
135                事实ID = ?id,
136                原因ID = ?cause,
137                队列长度 = new_queue.len(),
138                "处理状态转换类型事实"
139            );
140            obj.insert(
141                "type".into(),
142                serde_json::Value::String("StateTransition".into()),
143            );
144            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
145            obj.insert("cause".into(), serde_json::Value::Number(cause.0.into()));
146            obj.insert("new_payload".into(), tcb_to_serde(new_payload));
147            obj.insert(
148                "new_queue".into(),
149                serde_json::Value::Array(new_queue.iter().map(tcb_to_serde).collect()),
150            );
151        }
152        Fact::IoRequest {
153            id,
154            cause,
155            io_type,
156            params,
157        } => {
158            trace!(
159                事实ID = ?id,
160                原因ID = ?cause,
161                IO类型 = %io_type.as_str(),
162                "处理IO请求类型事实"
163            );
164            obj.insert("type".into(), serde_json::Value::String("IoRequest".into()));
165            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
166            obj.insert("cause".into(), serde_json::Value::Number(cause.0.into()));
167            obj.insert(
168                "io_type".into(),
169                serde_json::Value::String(io_type.as_str().into()),
170            );
171            obj.insert("params".into(), tcb_to_serde(params));
172        }
173        Fact::IoResponse {
174            id,
175            request_id,
176            result,
177            error,
178        } => {
179            trace!(
180                事实ID = ?id,
181                请求ID = ?request_id,
182                是否有错误 = error.is_some(),
183                "处理IO响应类型事实"
184            );
185            obj.insert(
186                "type".into(),
187                serde_json::Value::String("IoResponse".into()),
188            );
189            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
190            obj.insert(
191                "request_id".into(),
192                serde_json::Value::Number(request_id.0.into()),
193            );
194            obj.insert("result".into(), tcb_to_serde(result));
195            obj.insert(
196                "error".into(),
197                error
198                    .as_ref()
199                    .map(|e| serde_json::Value::String(e.clone()))
200                    .unwrap_or(serde_json::Value::Null),
201            );
202        }
203        Fact::Stable { id, version } => {
204            trace!(事实ID = ?id, 版本 = version, "处理稳定状态类型事实");
205            obj.insert("type".into(), serde_json::Value::String("Stable".into()));
206            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
207            obj.insert("version".into(), serde_json::Value::Number((*version).into()));
208        }
209        Fact::Error { id, message } => {
210            trace!(
211                事实ID = ?id,
212                消息 = %message,
213                "处理错误类型事实"
214            );
215            obj.insert("type".into(), serde_json::Value::String("Error".into()));
216            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
217            obj.insert("message".into(), serde_json::Value::String(message.clone()));
218        }
219    }
220
221    let value = serde_json::Value::Object(obj);
222
223    trace!(
224        事实ID = ?fact_id,
225        事实类型 = %fact_type,
226        "序列化完成"
227    );
228
229    Ok(value)
230}
231
232/// 计算内容的 BLAKE3 哈希
233///
234/// 将 `JsonValue` 序列化为确定性字符串后计算哈希。
235/// 使用 `tcb_to_serde` 进行显式 JSON 序列化,确保格式稳定性。
236///
237/// # 参数
238/// - `value`: 要计算哈希的 JSON 值
239///
240/// # 返回值
241/// - `Ok(String)`: 64 字符的十六进制哈希字符串
242/// - `Err(HashError)`: 序列化失败
243#[allow(dead_code)] // 供 evorule-governance re-export 使用
244pub fn content_hash(value: &JsonValue) -> Result<String, HashError> {
245    #[cfg(kani)]
246    {
247        // Kani 模式:blake3 的位操作循环会导致 CBMC memcmp 状态爆炸
248        // (4357+ 次循环展开未止)。用确定性简化哈希替代,保持幂等性。
249        // 注意:不用 format!(触发 core::unicode::skip_search 状态爆炸)。
250        Ok(String::from("c"))
251    }
252    #[cfg(not(kani))]
253    {
254        let serde_value = tcb_to_serde(value);
255        let serialized = serde_json::to_string(&serde_value)
256            .map_err(|e| HashError::new(format!("内容哈希序列化失败: {}", e)))?;
257        let hash = blake3::hash(serialized.as_bytes()).to_hex().to_string();
258
259        trace!(
260            序列化长度 = serialized.len(),
261            哈希值 = %hash,
262            "内容哈希计算完成"
263        );
264
265        Ok(hash)
266    }
267}
268
269/// 计算 Fact 的哈希(基于稳定的 JSON 序列化)
270///
271/// 使用显式 JSON 序列化格式而非 Debug trait,确保序列化格式的稳定性。
272///
273/// # 参数
274/// - `fact`: 要计算哈希的事实
275///
276/// # 返回值
277/// - `Ok(String)`: 64 字符的十六进制哈希字符串
278/// - `Err(HashError)`: 哈希计算失败
279///
280/// # 示例
281/// ```
282/// use evorule_reactor::{Fact, FactId, fact_hash};
283/// use evorule_tcb::JsonValue;
284///
285/// let fact = Fact::Command {
286///     id: FactId(1),
287///     instruction: JsonValue::empty_object(),
288/// };
289/// let hash = fact_hash(&fact).unwrap();
290/// assert_eq!(hash.len(), 64);
291/// ```
292pub fn fact_hash(fact: &Fact) -> Result<String, HashError> {
293    #[cfg(kani)]
294    {
295        // Kani 模式:blake3 的位操作循环会导致 CBMC memcmp 状态爆炸。
296        // 用确定性简化哈希替代:基于 id 的单字符映射(a-g),
297        // 保持幂等性(相同 id → 相同 hash)和区分性(不同 id → 不同 hash)。
298        // 不用 format!/to_string()/type_name(触发 Unicode 状态爆炸)。
299        let id = fact.id().0;
300        let h = match id % 7 {
301            0 => "a",
302            1 => "b",
303            2 => "c",
304            3 => "d",
305            4 => "e",
306            5 => "f",
307            _ => "g",
308        };
309        Ok(String::from(h))
310    }
311    #[cfg(not(kani))]
312    {
313        let fact_type = fact.type_name();
314        let fact_id = fact.id();
315
316        debug!(
317            事实ID = ?fact_id,
318            事实类型 = %fact_type,
319            "开始计算事实哈希"
320        );
321
322        let value = fact_to_stable_json(fact)?;
323        let serialized = serde_json::to_string(&value)
324            .map_err(|e| HashError::new(format!("序列化失败: {}", e)))?;
325
326        let hash = blake3::hash(serialized.as_bytes()).to_hex().to_string();
327
328        debug!(
329            事实ID = ?fact_id,
330            事实类型 = %fact_type,
331            哈希值 = %hash,
332            序列化长度 = serialized.len(),
333            "事实哈希计算完成"
334        );
335
336        Ok(hash)
337    }
338}
339
340/// 计算哈希链的最终链哈希
341///
342/// 这是审计链的核心计算函数。
343///
344/// # 算法
345/// - `prev_hash` 初始为 `"genesis"`
346/// - 对每个 Fact,计算 `content_hash = fact_hash(fact)`
347/// - 链哈希:`chain_hash = blake3(prev_hash + content_hash)`
348/// - 更新 `prev_hash = chain_hash`,继续处理下一个 Fact
349/// - 返回最终的 `prev_hash`(即整条链的链哈希)
350///
351/// # 参数
352/// - `facts`: 事实列表(按因果顺序)
353///
354/// # 返回值
355/// - `Ok(String)`: 最终链哈希(64 字符十六进制)
356/// - `Err(HashError)`: 任一 Fact 哈希计算失败
357///
358/// # 注意
359/// 本函数**只计算**链哈希,**不验证**完整性。
360/// 验证逻辑由调用方比对存储的哈希与重算的哈希。
361/// 空列表返回 `"genesis"`。
362pub fn compute_chain_hash(facts: &[Fact]) -> Result<String, HashError> {
363    let fact_count = facts.len();
364
365    debug!(事实数量 = fact_count, "开始计算链哈希");
366
367    if fact_count == 0 {
368        debug!("事实列表为空,返回 genesis 哈希");
369        return Ok(String::from("genesis"));
370    }
371
372    let mut prev_hash = String::from("genesis");
373
374    trace!(
375        初始前序哈希 = %prev_hash,
376        "初始化前序哈希为创世值"
377    );
378
379    for (index, fact) in facts.iter().enumerate() {
380        let fact_type = fact.type_name();
381        let fact_id = fact.id();
382
383        trace!(
384            索引 = index,
385            事实ID = ?fact_id,
386            事实类型 = %fact_type,
387            前序哈希 = %prev_hash,
388            "处理事实"
389        );
390
391        let fh = fact_hash(fact)?;
392
393        trace!(
394            索引 = index,
395            事实ID = ?fact_id,
396            事实哈希 = %fh,
397            "事实哈希计算完成"
398        );
399
400        let current = chain_step(&prev_hash, &fh);
401
402        trace!(
403            索引 = index,
404            事实ID = ?fact_id,
405            当前哈希 = %current,
406            "计算当前哈希(前序哈希+事实哈希)"
407        );
408
409        prev_hash = current;
410    }
411
412    debug!(
413        事实数量 = fact_count,
414        最终哈希 = %prev_hash,
415        "链哈希计算完成"
416    );
417
418    Ok(prev_hash)
419}
420
421/// 计算单步链哈希:chain_hash = blake3(prev_hash + content_hash)
422///
423/// Kani 模式下用确定性简化版本(字符串拼接)替代 blake3,
424/// 避免 CBMC 对 blake3 位操作循环的 memcmp 状态爆炸。
425/// 保持链步的结合性:`chain_step(chain_step(a, b), c)` 可归纳分解。
426///
427/// # 参数
428/// - `prev_hash`: 前序链哈希(初始为 `"genesis"`)
429/// - `content_hash`: 当前 Fact 的内容哈希
430///
431/// # 返回值
432/// 新的链哈希
433pub fn chain_step(prev_hash: &str, content_hash: &str) -> String {
434    #[cfg(kani)]
435    {
436        // Kani 模式:确定性简化,保持链步结构。
437        // 不用 format!(触发 core::unicode::skip_search 状态爆炸),
438        // 用 String::from + push_str(字节级操作,短字符串)。
439        let mut s = String::from(prev_hash);
440        s.push_str(content_hash);
441        s
442    }
443    #[cfg(not(kani))]
444    {
445        let combined = format!("{}{}", prev_hash, content_hash);
446        blake3::hash(combined.as_bytes()).to_hex().to_string()
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
453    use super::*;
454    use crate::fact::{Fact, FactId, IoType};
455    use evorule_tcb::JsonValue;
456
457    #[test]
458    fn test_fact_to_stable_json_format() {
459        let command = Fact::Command {
460            id: FactId(1),
461            instruction: JsonValue::object_from_pairs(&[
462                ("type", JsonValue::string("increment")),
463                (
464                    "params",
465                    JsonValue::object_from_pairs(&[
466                        ("attr", JsonValue::string("x")),
467                        ("delta", JsonValue::Integer(5)),
468                    ]),
469                ),
470            ]),
471        };
472
473        let json_value = fact_to_stable_json(&command).unwrap();
474
475        assert_eq!(json_value.get("type").unwrap().as_str().unwrap(), "Command");
476        assert_eq!(json_value.get("id").unwrap().as_u64().unwrap(), 1);
477        assert!(json_value.get("instruction").is_some());
478    }
479
480    #[test]
481    fn test_fact_hash_all_variants() {
482        let test_facts = vec![
483            Fact::Command {
484                id: FactId(1),
485                instruction: JsonValue::empty_object(),
486            },
487            Fact::PayloadUpdate {
488                id: FactId(2),
489                path: "test.path".into(),
490                value: JsonValue::string("test_value"),
491            },
492            Fact::StateTransition {
493                id: FactId(3),
494                cause: FactId(1),
495                new_payload: JsonValue::empty_object(),
496                new_queue: vec![],
497            },
498            Fact::IoRequest {
499                id: FactId(4),
500                cause: FactId(3),
501                io_type: IoType::http_get(),
502                params: JsonValue::empty_object(),
503            },
504            Fact::IoResponse {
505                id: FactId(5),
506                request_id: FactId(4),
507                result: JsonValue::string("response"),
508                error: None,
509            },
510            Fact::IoResponse {
511                id: FactId(6),
512                request_id: FactId(4),
513                result: JsonValue::Null,
514                error: Some("timeout".to_string()),
515            },
516            Fact::Stable {
517                id: FactId(7),
518                version: 0,
519            },
520            Fact::Error {
521                id: FactId(8),
522                message: "test error".into(),
523            },
524        ];
525
526        for fact in test_facts {
527            let hash = fact_hash(&fact).unwrap();
528            assert_eq!(hash.len(), 64);
529
530            let hash2 = fact_hash(&fact).unwrap();
531            assert_eq!(
532                hash,
533                hash2,
534                "fact_hash should be deterministic for {}",
535                fact.type_name()
536            );
537        }
538    }
539
540    #[test]
541    fn test_fact_hash_identity() {
542        let fact1 = Fact::Command {
543            id: FactId(1),
544            instruction: JsonValue::string("same"),
545        };
546        let fact2 = Fact::Command {
547            id: FactId(1),
548            instruction: JsonValue::string("same"),
549        };
550
551        assert_eq!(fact_hash(&fact1).unwrap(), fact_hash(&fact2).unwrap());
552    }
553
554    #[test]
555    fn test_fact_hash_different_ids() {
556        let fact1 = Fact::Command {
557            id: FactId(1),
558            instruction: JsonValue::string("same"),
559        };
560        let fact2 = Fact::Command {
561            id: FactId(2),
562            instruction: JsonValue::string("same"),
563        };
564
565        assert_ne!(fact_hash(&fact1).unwrap(), fact_hash(&fact2).unwrap());
566    }
567
568    #[test]
569    fn test_fact_hash_snapshot() {
570        let test_facts = [
571            Fact::Command {
572                id: FactId(1),
573                instruction: JsonValue::empty_object(),
574            },
575            Fact::PayloadUpdate {
576                id: FactId(2),
577                path: "test.path".into(),
578                value: JsonValue::string("test_value"),
579            },
580            Fact::StateTransition {
581                id: FactId(3),
582                cause: FactId(1),
583                new_payload: JsonValue::empty_object(),
584                new_queue: vec![],
585            },
586            Fact::IoRequest {
587                id: FactId(4),
588                cause: FactId(3),
589                io_type: IoType::http_get(),
590                params: JsonValue::empty_object(),
591            },
592            Fact::IoResponse {
593                id: FactId(5),
594                request_id: FactId(4),
595                result: JsonValue::string("response"),
596                error: None,
597            },
598            Fact::Stable {
599                id: FactId(6),
600                version: 0,
601            },
602            Fact::Error {
603                id: FactId(7),
604                message: "test error".into(),
605            },
606        ];
607
608        let current_hashes: Vec<String> =
609            test_facts.iter().map(|f| fact_hash(f).unwrap()).collect();
610        let snapshot_file = env!("CARGO_MANIFEST_DIR").to_string() + "/hash_snapshot.txt";
611
612        if std::path::Path::new(&snapshot_file).exists() {
613            let snapshot = std::fs::read_to_string(&snapshot_file).unwrap();
614            let expected_hashes: Vec<String> = snapshot.lines().map(|s| s.to_string()).collect();
615            assert_eq!(
616                current_hashes, expected_hashes,
617                "Hash snapshot mismatch! If this is an expected change (e.g., Fact struct modification), delete {} to regenerate.",
618                snapshot_file
619            );
620        } else {
621            let snapshot_content = current_hashes.join("\n");
622            std::fs::write(&snapshot_file, snapshot_content).unwrap();
623            println!("Created hash snapshot: {}", snapshot_file);
624        }
625    }
626
627    #[test]
628    fn test_compute_chain_hash_empty() {
629        let facts: Vec<Fact> = vec![];
630        let chain_hash = compute_chain_hash(&facts).unwrap();
631        assert_eq!(chain_hash, "genesis");
632    }
633
634    #[test]
635    fn test_compute_chain_hash_deterministic() {
636        let facts = vec![
637            Fact::Command {
638                id: FactId(1),
639                instruction: JsonValue::empty_object(),
640            },
641            Fact::StateTransition {
642                id: FactId(2),
643                cause: FactId(1),
644                new_payload: JsonValue::empty_object(),
645                new_queue: vec![],
646            },
647        ];
648
649        let result1 = compute_chain_hash(&facts).unwrap();
650        let result2 = compute_chain_hash(&facts).unwrap();
651        assert_eq!(result1, result2);
652    }
653
654    #[test]
655    fn test_compute_chain_hash_order_sensitive() {
656        let fact1 = Fact::Command {
657            id: FactId(1),
658            instruction: JsonValue::empty_object(),
659        };
660        let fact2 = Fact::StateTransition {
661            id: FactId(2),
662            cause: FactId(1),
663            new_payload: JsonValue::empty_object(),
664            new_queue: vec![],
665        };
666
667        let chain1 = compute_chain_hash(&[fact1.clone(), fact2.clone()]).unwrap();
668        let chain2 = compute_chain_hash(&[fact2, fact1]).unwrap();
669        assert_ne!(chain1, chain2, "链哈希应对 Fact 顺序敏感");
670    }
671
672    /// 交叉验证测试:与 evorule-governance/src/hash.rs 的快照比对
673    ///
674    /// 本测试确保 tier1 和 tier2 的哈希算法产生相同的哈希值。
675    /// 如果 tier2 hash.rs 仍维护独立实现,此测试会检测到不一致。
676    #[test]
677    fn test_cross_validate_with_tier2() {
678        // 与 evorule-governance/src/hash.rs 的 test_fact_hash_snapshot 使用相同的 Fact
679        let test_facts = [
680            Fact::Command {
681                id: FactId(1),
682                instruction: JsonValue::empty_object(),
683            },
684            Fact::PayloadUpdate {
685                id: FactId(2),
686                path: "test.path".into(),
687                value: JsonValue::string("test_value"),
688            },
689            Fact::StateTransition {
690                id: FactId(3),
691                cause: FactId(1),
692                new_payload: JsonValue::empty_object(),
693                new_queue: vec![],
694            },
695            Fact::IoRequest {
696                id: FactId(4),
697                cause: FactId(3),
698                io_type: IoType::http_get(),
699                params: JsonValue::empty_object(),
700            },
701            Fact::IoResponse {
702                id: FactId(5),
703                request_id: FactId(4),
704                result: JsonValue::string("response"),
705                error: None,
706            },
707            Fact::Stable {
708                id: FactId(6),
709                version: 0,
710            },
711            Fact::Error {
712                id: FactId(7),
713                message: "test error".into(),
714            },
715        ];
716
717        // 计算 tier1 的哈希
718        let tier1_hashes: Vec<String> = test_facts.iter().map(|f| fact_hash(f).unwrap()).collect();
719
720        // 读取 tier2 的快照文件(如果存在)
721        let tier2_snapshot_path = "../../evorule-governance/src/hash_snapshot.txt".to_string();
722        if std::path::Path::new(&tier2_snapshot_path).exists() {
723            let tier2_snapshot = std::fs::read_to_string(&tier2_snapshot_path).unwrap();
724            let tier2_hashes: Vec<String> = tier2_snapshot.lines().map(|s| s.to_string()).collect();
725
726            assert_eq!(
727                tier1_hashes, tier2_hashes,
728                "tier1 和 tier2 的 fact_hash 不一致!\
729                 如果这是预期变更(如 Fact 结构修改),\
730                 请删除 evorule-governance/src/hash_snapshot.txt 重新生成。"
731            );
732        }
733    }
734}