Skip to main content

evorule_cli/commands/
scan_wal_dir.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//! 审计链哨兵:定时扫描 WAL 目录,逐文件全链验证 + 跨扫描基线比对
5//!
6//! # 双层比对模型
7//! - **尺子自检**(黄金向量):扫描前断言内置 7 事实哈希快照与锚点黄金向量——
8//!   证明验证器自身忠实(计量校准思想)。自检失败=工具红(退出码 2),
9//!   不产出数据结论。
10//! - **数据比对**(基线前缀锚定):基线记录每文件 `链尾+记录数`。追加判定=
11//!   重放本轮链至上轮记录数位置,断言链尾==上轮基线值——精确区分「纯追加」
12//!   与「历史被改后整链重算」(后者前缀锚定必失配)。旧链尾同位变化/回退/
13//!   文件消失分别对应退出码 3/3/4。
14//!
15//! # 退出码协议(调度器只认退出码)
16//! `0` 全绿/正常追加/首跑建基线;`2` 工具自检失败(尺子坏);`3` 链破坏
17//! (验证失配/前缀锚定失配/同位链尾变化/记录回退);`4` 基线有而磁盘无
18//! (文件消失);`5` 半行/损坏拒收(衔接 `truncate-partial-line` 受控恢复)。
19//!
20//! # 无哈希账本口径
21//! `shared_facts.wal`(共享事实账本,暂无哈希链)做结构完整性记录
22//! (行数/字节/内容 BLAKE3 摘要入基线),变化检测照常、链验证跳过并如实
23//! 标注 `unhashed-ledger`。
24
25use std::collections::BTreeMap;
26use std::io::Write as _;
27use std::path::{Path, PathBuf};
28
29use clap::Args;
30use evorule_reactor::{chain_step, fact_hash, read_wal_with_hash};
31
32use crate::error::CliError;
33
34/// 内核 SSOT 快照值(evorule-audit-chain/hash_snapshot.txt;有意变更须同批同步)
35const KERNEL_HASH_SNAPSHOT: [&str; 7] = [
36    "56de9ce95d41896edf081f452b4ea5d3ad6508de3a0781a36fa5fbd13c586aa1",
37    "902f22869450a78851d5bf1396b64d4bc7be0666468a39d2dac53b7d176f8047",
38    "6c4a12ee89a692181a07ede80b0864f0774286427fce67156944f6f33290ddf7",
39    "4855b360063f11eeffd7b8e2bd5720c641451012a688ebec489b5f8f059058aa",
40    "867c9f708b7469ffb25682f997792b8811f34ef61eb2ee4ba23964e1c20f7909",
41    "ac02d9e15a497b30c18ce5019a50aeced2d5d186a973ee3a0985ee728054a015",
42    "42ca4e53611616707514550f1d2ab8a89be5ef8bd1a5f6cbd07e016bdb931afb",
43];
44
45const EXIT_OK: u8 = 0;
46const EXIT_SELF_TEST: u8 = 2;
47const EXIT_CHAIN_BROKEN: u8 = 3;
48const EXIT_FILE_GONE: u8 = 4;
49const EXIT_CORRUPT_REJECT: u8 = 5;
50
51#[derive(Debug, Args)]
52pub struct ScanWalDir {
53    /// WAL 目录(session_*.wal* / shared_facts.wal / governance_single_reactor.wal)
54    pub wal_dir: PathBuf,
55
56    /// 基线文件(跨扫描比对;首跑创建,其后每次更新)
57    #[arg(long)]
58    pub baseline: Option<PathBuf>,
59
60    /// 哨兵日志(append-only,每次扫描每文件一行;建议独立目录)
61    #[arg(long)]
62    pub sentinel_log: Option<PathBuf>,
63
64    /// 机器可读 JSON 报告输出文件
65    #[arg(long)]
66    pub json: Option<PathBuf>,
67}
68
69/// 单文件的本轮观测
70#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
71struct FileObs {
72    records: u64,
73    /// 链尾(unhashed 账本为内容 BLAKE3 摘要)
74    tail: String,
75    bytes: u64,
76    verdict: String,
77}
78
79#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
80struct Baseline {
81    files: BTreeMap<String, FileObs>,
82}
83
84/// 黄金向量自检:哈希快照 7 值 + genesis 语义 + 锚点黄金向量
85fn self_test() -> Result<(), CliError> {
86    use evorule_reactor::{Fact, FactId, IoType};
87    use evorule_tcb::JsonValue;
88
89    // ① 7 事实哈希快照(与内核 SSOT 逐字节)
90    let facts = [
91        Fact::Command {
92            id: FactId(1),
93            instruction: JsonValue::empty_object(),
94        },
95        Fact::PayloadUpdate {
96            id: FactId(2),
97            path: "test.path".into(),
98            value: JsonValue::string("test_value"),
99        },
100        Fact::StateTransition {
101            id: FactId(3),
102            cause: FactId(1),
103            new_payload: JsonValue::empty_object(),
104            new_queue: vec![],
105        },
106        Fact::IoRequest {
107            id: FactId(4),
108            cause: FactId(3),
109            io_type: IoType::http_get(),
110            params: JsonValue::empty_object(),
111        },
112        Fact::IoResponse {
113            id: FactId(5),
114            request_id: FactId(4),
115            result: JsonValue::string("response"),
116            error: None,
117        },
118        Fact::Stable {
119            id: FactId(6),
120            version: 0,
121        },
122        Fact::Error {
123            id: FactId(7),
124            message: "test error".into(),
125        },
126    ];
127    for (i, f) in facts.iter().enumerate() {
128        let h = fact_hash(f)
129            .map_err(|e| crate::error::CliError::other(format!("self-test hash: {e}")))?;
130        if h != KERNEL_HASH_SNAPSHOT[i] {
131            return Err(CliError::other(format!(
132                "self-test FAILED: golden fact hash #{}/7 mismatch (verifier drifted from kernel SSOT)",
133                i + 1
134            )));
135        }
136    }
137
138    // ② genesis 语义(空链链尾)
139    // compute_chain_hash(&[]) == "genesis" 由内核测试守护;此处验证 chain_step 起点
140    let first = chain_step("genesis", KERNEL_HASH_SNAPSHOT[0]);
141    if first.len() != 64 || !first.bytes().all(|b| b.is_ascii_hexdigit()) {
142        return Err(CliError::other("self-test FAILED: chain_step output shape"));
143    }
144
145    // ③ 锚点黄金向量(固定 seed 黄金锚点):v2 载荷从锚点字段独立重构 + 本地
146    // ed25519 验签——自检密码学元件与载荷格式忠实性(锚点链构造正确性由上游
147    // governance 黄金向量测试守护,此处不重复)
148    use crate::signing::{hex_decode, verify_signature, AuditSigner};
149    const GOLDEN_PK: &str = "248acbdbaf9e050196de704bea2d68770e519150d103b587dae2d9cad53dd930";
150    let signer = AuditSigner::from_bytes([0xabu8; 32]);
151    let pk = signer.verifying_bytes();
152    if crate::signing::hex_encode(&pk) != GOLDEN_PK {
153        return Err(CliError::other(
154            "self-test FAILED: anchor golden pubkey mismatch",
155        ));
156    }
157    // 黄金锚点 0:seq=0, range [0,5), chain_head=a×64(与 governance 黄金向量同源)
158    let payload0 = format!(
159        "evorule-anchor/2|vector-sess-1|0|0|5|{}|anchor-key-1|vector-engine/0.1|0",
160        "a".repeat(64)
161    );
162    let sig0_hex = "bb9fe4f555b816edef9e0f1438e07a3dfa944688324b489304ba0923a371024217683c56f0d0d30a6bbf1efda8dad0ba0bca829a6ca4e6598b2cf4df167e2507";
163    let sig0: [u8; 64] = hex_decode(sig0_hex)
164        .map_err(|e| CliError::other(format!("self-test hex: {e}")))?
165        .try_into()
166        .map_err(|_| CliError::other("self-test sig length"))?;
167    let ok = verify_signature(pk, payload0.as_bytes(), &sig0)
168        .map_err(|e| CliError::other(format!("self-test verify: {e}")))?;
169    if !ok {
170        return Err(CliError::other(
171            "self-test FAILED: anchor golden signature verify",
172        ));
173    }
174    Ok(())
175}
176
177/// 解析会话 WAL 文件名:session_{n}.wal 或轮换分片 session_{n}.wal.{seq}
178fn session_wal_key(name: &str) -> Option<String> {
179    let rest = name.strip_prefix("session_")?;
180    let pos = rest.find(".wal")?;
181    let id_part = &rest[..pos];
182    let after = &rest[pos + 4..];
183    let valid_suffix = after.is_empty()
184        || (after.len() > 1
185            && after.starts_with('.')
186            && after[1..].bytes().all(|b| b.is_ascii_digit()));
187    if !valid_suffix {
188        return None;
189    }
190    id_part
191        .parse::<u64>()
192        .ok()
193        .map(|id| format!("session:{id}"))
194}
195
196/// 归并同会话的分片(基线以会话为键:分片集合整体观测)
197fn classify(dir: &Path) -> std::result::Result<BTreeMap<String, Vec<PathBuf>>, String> {
198    let mut groups: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
199    for entry in std::fs::read_dir(dir)
200        .map_err(|e| format!("read wal dir: {e}"))?
201        .flatten()
202    {
203        let name = entry.file_name().to_string_lossy().to_string();
204        if let Some(key) = session_wal_key(&name) {
205            groups.entry(key).or_default().push(entry.path());
206        } else if name == "shared_facts.wal" {
207            groups
208                .entry("shared_facts".into())
209                .or_default()
210                .push(entry.path());
211        } else if name == "governance_single_reactor.wal" {
212            groups
213                .entry("governance_single_reactor".into())
214                .or_default()
215                .push(entry.path());
216        }
217    }
218    for v in groups.values_mut() {
219        v.sort();
220    }
221    Ok(groups)
222}
223
224/// 全链三查 + 链哈希轨迹(前缀锚定用)
225fn verify_group(paths: &[PathBuf]) -> std::result::Result<(Vec<String>, u64, u64), String> {
226    // paths 已排序:主文件 → .1 → .2(与写入时间序一致)
227    let mut prev = "genesis".to_string();
228    let mut chain_hashes: Vec<String> = Vec::new();
229    let mut bytes = 0u64;
230    for p in paths {
231        let meta = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?;
232        bytes += meta.len();
233        let records = read_wal_with_hash(p).map_err(|e| format!("{}: {e}", p.display()))?;
234        for rec in &records {
235            let content = fact_hash(&rec.fact).map_err(|e| format!("{}: {e}", p.display()))?;
236            if let Some(stored) = &rec.content_hash {
237                if stored != &content {
238                    return Err(format!(
239                        "{}: content_hash mismatch at fact {}",
240                        p.display(),
241                        rec.fact.id().0
242                    ));
243                }
244            }
245            if let Some(stored_prev) = &rec.prev_hash {
246                if stored_prev != &prev {
247                    return Err(format!(
248                        "{}: chain broken at fact {} (stored_prev != expected)",
249                        p.display(),
250                        rec.fact.id().0
251                    ));
252                }
253            }
254            let chain = chain_step(&prev, &content);
255            if let Some(stored_chain) = &rec.chain_hash {
256                if stored_chain != &chain {
257                    return Err(format!(
258                        "{}: chain_hash mismatch at fact {}",
259                        p.display(),
260                        rec.fact.id().0
261                    ));
262                }
263            }
264            prev = chain;
265            chain_hashes.push(prev.clone());
266        }
267    }
268    let count = chain_hashes.len() as u64;
269    Ok((chain_hashes, count, bytes))
270}
271
272/// 无哈希账本:结构完整性摘要(当前无在役调用者;保留供未来无链账本)
273#[allow(dead_code)]
274fn digest_group(paths: &[PathBuf]) -> std::result::Result<(String, u64, u64), String> {
275    let mut bytes = 0u64;
276    let mut lines = 0u64;
277    let mut hasher = blake3::Hasher::new();
278    for p in paths {
279        let data = std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?;
280        bytes += data.len() as u64;
281        lines += data.iter().filter(|&&b| b == b'\n').count() as u64;
282        hasher.update(&data);
283    }
284    Ok((hasher.finalize().to_hex().to_string(), lines, bytes))
285}
286
287fn bump(code: u8, worst: &mut u8) {
288    // 严重度序:3 > 4 > 5 > 2 > 0(自检已在 run 前短路,此处不会有 2)
289    let rank = |c: u8| match c {
290        EXIT_CHAIN_BROKEN => 4,
291        EXIT_FILE_GONE => 3,
292        EXIT_CORRUPT_REJECT => 2,
293        _ => 0,
294    };
295    if rank(code) > rank(*worst) {
296        *worst = code;
297    }
298}
299
300/// 基线前滚 + JSON 报告 + 人读摘要与处置指引
301fn finalize_and_report(
302    cmd: &ScanWalDir,
303    now: &Baseline,
304    worst: &mut u8,
305    report_lines: &[String],
306    first_run: bool,
307    baseline_path: &Path,
308) {
309    // 写新基线(仅在无破坏时前滚;有破坏保留旧基线供取证比对;写出失败降级为
310    // 告警——输出面故障不得改变扫描结论的退出码语义)
311    if *worst == EXIT_OK {
312        match serde_json::to_string_pretty(&now) {
313            Ok(out) => {
314                if let Err(e) = std::fs::write(baseline_path, out) {
315                    eprintln!("[SENTINEL] baseline write failed: {e}");
316                }
317            }
318            Err(e) => eprintln!("[SENTINEL] baseline serialize failed: {e}"),
319        }
320    }
321
322    if let Some(json_path) = &cmd.json {
323        let report = serde_json::json!({
324            "wal_dir": cmd.wal_dir.display().to_string(),
325            "first_run": first_run,
326            "exit_code": worst,
327            "files": now.files,
328            "lines": report_lines,
329        });
330        match serde_json::to_string_pretty(&report)
331            .map_err(|e| e.to_string())
332            .and_then(|j| std::fs::write(json_path, j).map_err(|e| e.to_string()))
333        {
334            Ok(()) => {}
335            Err(e) => eprintln!("[SENTINEL] json report write failed: {e}"),
336        }
337    }
338
339    println!(
340        "[SENTINEL] scan {} files/groups, exit={worst}",
341        now.files.len()
342    );
343    for l in report_lines {
344        println!("  {l}");
345    }
346    if *worst == EXIT_CORRUPT_REJECT {
347        println!("  处置指引:半行/损坏可用 `evorule truncate-partial-line <file> --confirm` 受控恢复(前缀链验证+备份)");
348    }
349}
350
351/// 哨兵日志(append-only):`ts | key | records | tail | verdict | delta`
352fn append_sentinel_log(
353    path: &Option<PathBuf>,
354    key: &str,
355    obs: &FileObs,
356    verdict_label: &str,
357    comparison: &str,
358) {
359    let Some(sl) = path else { return };
360    let ts = std::time::SystemTime::now()
361        .duration_since(std::time::UNIX_EPOCH)
362        .map(|d| d.as_secs())
363        .unwrap_or(0);
364    if let Some(parent) = sl.parent() {
365        let _ = std::fs::create_dir_all(parent);
366    }
367    if let Ok(mut f) = std::fs::OpenOptions::new()
368        .create(true)
369        .append(true)
370        .open(sl)
371    {
372        let _ = writeln!(
373            f,
374            "{ts} | {key} | {} | {} | {} | {comparison}",
375            obs.records, obs.tail, verdict_label
376        );
377    }
378}
379
380/// 基线比对(前缀锚定):obs=本轮观测,prev=上轮基线条目(None=新文件)
381fn compare_with_baseline(
382    obs: &FileObs,
383    prev: Option<&FileObs>,
384    unhashed: bool,
385    paths: &[PathBuf],
386    worst: &mut u8,
387) -> String {
388    match prev {
389        None => "new-file".to_string(),
390        Some(prev_obs) => {
391            if unhashed {
392                if prev_obs.tail == obs.tail {
393                    "unchanged".to_string()
394                } else {
395                    // 无哈希账本:内容变化如实报(追加与篡改不可分——账本无链可锚)
396                    bump(EXIT_CHAIN_BROKEN, worst);
397                    "UNHASHED-CHANGED(内容变化:正常追加或篡改不可分,人工确认)".to_string()
398                }
399            } else if obs.records < prev_obs.records {
400                bump(EXIT_CHAIN_BROKEN, worst);
401                format!(
402                    "REGRESSED(记录回退:上轮 {} → 本轮 {})",
403                    prev_obs.records, obs.records
404                )
405            } else if obs.records == prev_obs.records {
406                if obs.tail == prev_obs.tail {
407                    "unchanged".to_string()
408                } else {
409                    bump(EXIT_CHAIN_BROKEN, worst);
410                    "TAIL-CHANGED-SAME-LENGTH(同位链尾变化=篡改)".to_string()
411                }
412            } else {
413                // 记录增加:前缀锚定——重放至上轮记录数处链尾必须==上轮 tail
414                match verify_group(paths) {
415                    Ok((chain, _, _)) => {
416                        let anchor_idx = (prev_obs.records as usize).saturating_sub(1);
417                        match chain.get(anchor_idx) {
418                            Some(h) if h == &prev_obs.tail => {
419                                "appended(前缀锚定通过)".to_string()
420                            }
421                            _ => {
422                                bump(EXIT_CHAIN_BROKEN, worst);
423                                "PREFIX-ANCHOR-MISMATCH(历史被改后重算/分片错位)".to_string()
424                            }
425                        }
426                    }
427                    Err(e) => {
428                        bump(EXIT_CHAIN_BROKEN, worst);
429                        format!("RECHECK-FAILED: {e}")
430                    }
431                }
432            }
433        }
434    }
435}
436
437pub fn run(cmd: &ScanWalDir) -> Result<(), CliError> {
438    // ===== 层 A:尺子自检(失败=码 2,不产出数据结论)=====
439    if let Err(e) = self_test() {
440        eprintln!("[SENTINEL] SELF-TEST FAILED(验证器不可信,本轮不产出数据结论): {e}");
441        return Err(CliError::SentinelExit(EXIT_SELF_TEST));
442    }
443
444    let groups = classify(&cmd.wal_dir).map_err(CliError::other)?;
445    let baseline_path = cmd
446        .baseline
447        .clone()
448        .unwrap_or_else(|| cmd.wal_dir.join("sentinel-baseline.json"));
449    let old: Baseline = match std::fs::read_to_string(&baseline_path) {
450        Ok(s) => serde_json::from_str(&s)
451            .map_err(|e| crate::error::CliError::other(format!("baseline parse: {e}")))?,
452        Err(_) => Baseline::default(), // 首跑
453    };
454    let first_run = old.files.is_empty();
455
456    let mut now = Baseline::default();
457    let mut worst: u8 = EXIT_OK;
458    let mut report_lines: Vec<String> = Vec::new();
459
460    for (key, paths) in &groups {
461        // shared_facts.wal 自内核 WAL v2 起经 FactsLog::append 链化(实证带
462        // 三元组)——走链三查+前缀锚定路径;unhashed-ledger 口径仅保留给
463        // 确无哈希链的账本(当前无)
464        let unhashed = false;
465        let obs_result = if unhashed {
466            digest_group(paths).map(|(digest, lines, bytes)| FileObs {
467                records: lines,
468                tail: digest,
469                bytes,
470                verdict: "unhashed-ledger".into(),
471            })
472        } else {
473            verify_group(paths).map(|(chain, records, bytes)| FileObs {
474                records,
475                tail: chain.last().cloned().unwrap_or_else(|| "genesis".into()),
476                bytes,
477                verdict: "verified".into(),
478            })
479        };
480
481        match obs_result {
482            Ok(obs) => {
483                let verdict_label = obs.verdict.clone();
484                // ===== 层 B:基线比对(前缀锚定)=====
485                let comparison =
486                    compare_with_baseline(&obs, old.files.get(key), unhashed, paths, &mut worst);
487                report_lines.push(format!(
488                    "{key}: {} records, tail {}..{}, verdict={verdict_label}, delta={comparison}",
489                    obs.records,
490                    &obs.tail[..8.min(obs.tail.len())],
491                    &obs.tail[obs.tail.len().saturating_sub(8)..]
492                ));
493                append_sentinel_log(&cmd.sentinel_log, key, &obs, &verdict_label, &comparison);
494                now.files.insert(key.clone(), obs);
495            }
496            Err(e) => {
497                // 解析/验证失败:区分「失配」(码 3)与「半行/损坏拒收」(码 5)
498                let partial = e.contains("JSON parse error") || e.contains("EOF while parsing");
499                let code = if partial {
500                    EXIT_CORRUPT_REJECT
501                } else {
502                    EXIT_CHAIN_BROKEN
503                };
504                bump(code, &mut worst);
505                report_lines.push(format!("{key}: REJECTED ({e})"));
506                // 拒收文件仍入本轮观测(verdict=rejected 占位):
507                // 文件物理存在,不得被「文件消失」分支误报(码 4 与码 5 语义隔离)
508                let bytes_now = paths
509                    .iter()
510                    .filter_map(|p| std::fs::metadata(p).ok())
511                    .map(|m| m.len())
512                    .sum();
513                now.files.insert(
514                    key.clone(),
515                    FileObs {
516                        records: 0,
517                        tail: String::new(),
518                        bytes: bytes_now,
519                        verdict: "rejected".into(),
520                    },
521                );
522            }
523        }
524    }
525
526    // 基线中存在而磁盘消失的会话 → 码 4
527    for key in old.files.keys() {
528        if !now.files.contains_key(key) && !first_run {
529            bump(EXIT_FILE_GONE, &mut worst);
530            report_lines.push(format!("{key}: FILE-GONE(基线有而磁盘无——截断/误删)"));
531        }
532    }
533
534    finalize_and_report(
535        cmd,
536        &now,
537        &mut worst,
538        &report_lines,
539        first_run,
540        &baseline_path,
541    );
542    if worst == EXIT_OK {
543        Ok(())
544    } else {
545        Err(CliError::SentinelExit(worst))
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    #![allow(clippy::unwrap_used, clippy::expect_used)]
552    use super::*;
553
554    /// 自检必须通过(黄金向量=尺子校准的本体测试)
555    #[test]
556    fn self_test_passes_with_current_binaries() {
557        assert!(self_test().is_ok(), "哨兵自检失败:验证器与黄金向量漂移");
558    }
559
560    /// 内置快照常量必须与内核 SSOT 快照文件逐行一致(跨仓锁定的编译期+测试期双保险)
561    #[test]
562    fn builtin_snapshot_matches_kernel_file() {
563        let kernel_snapshot = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
564            .join("../evorule-audit-chain/hash_snapshot.txt");
565        let content = std::fs::read_to_string(&kernel_snapshot)
566            .expect("内核 SSOT 快照文件应存在(同 workspace)");
567        let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
568        assert_eq!(lines.len(), 7, "内核快照应为 7 行");
569        for (i, line) in lines.iter().enumerate() {
570            assert_eq!(
571                *line,
572                KERNEL_HASH_SNAPSHOT[i],
573                "第 {} 行与内置常量不一致",
574                i + 1
575            );
576        }
577    }
578
579    /// 会话文件名归并(含轮换分片与非会话文件排除)
580    #[test]
581    fn session_wal_key_classification() {
582        assert_eq!(
583            session_wal_key("session_3.wal").as_deref(),
584            Some("session:3")
585        );
586        assert_eq!(
587            session_wal_key("session_12.wal.7").as_deref(),
588            Some("session:12")
589        );
590        assert_eq!(session_wal_key("shared_facts.wal"), None);
591        assert_eq!(session_wal_key("session_x.wal"), None);
592        assert_eq!(session_wal_key("notes.txt"), None);
593    }
594}