1use 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
34const 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 pub wal_dir: PathBuf,
55
56 #[arg(long)]
58 pub baseline: Option<PathBuf>,
59
60 #[arg(long)]
62 pub sentinel_log: Option<PathBuf>,
63
64 #[arg(long)]
66 pub json: Option<PathBuf>,
67}
68
69#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
71struct FileObs {
72 records: u64,
73 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
84fn self_test() -> Result<(), CliError> {
86 use evorule_reactor::{Fact, FactId, IoType};
87 use evorule_tcb::JsonValue;
88
89 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 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 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 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
177fn 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
196fn 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
224fn verify_group(paths: &[PathBuf]) -> std::result::Result<(Vec<String>, u64, u64), String> {
226 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#[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 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
300fn 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 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
351fn 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
380fn 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 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 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 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(), };
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 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 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 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 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 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 #[test]
556 fn self_test_passes_with_current_binaries() {
557 assert!(self_test().is_ok(), "哨兵自检失败:验证器与黄金向量漂移");
558 }
559
560 #[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 #[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}