use crate::clock::LogicalClock;
use crate::hash;
use evorule_reactor::{Fact, FactId, FactsLog};
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct AuditEntry {
pub fact_id: FactId,
pub fact_type: &'static str,
pub logical_time: u64,
pub content_hash: String,
pub prev_hash: String,
pub cause: Option<FactId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadError {
IoError(String),
HashError(String),
ContentHashMismatch {
index: usize,
fact_id: FactId,
stored: String,
recomputed: String,
},
ChainBroken {
index: usize,
fact_id: FactId,
stored_prev: String,
expected_prev: String,
},
ChainHashMismatch {
index: usize,
fact_id: FactId,
stored: String,
recomputed: String,
},
}
impl core::fmt::Display for LoadError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
LoadError::IoError(msg) => write!(f, "WAL 读取失败: {msg}"),
LoadError::HashError(msg) => write!(f, "哈希计算失败: {msg}"),
LoadError::ContentHashMismatch {
index,
fact_id,
stored,
recomputed,
} => write!(
f,
"Fact[{}] (id={}) 内容哈希不匹配: stored={stored}, recomputed={recomputed}",
index, fact_id.0
),
LoadError::ChainBroken {
index,
fact_id,
stored_prev,
expected_prev,
} => write!(
f,
"Fact[{}] (id={}) 哈希链断裂: stored_prev={stored_prev}, expected_prev={expected_prev}",
index, fact_id.0
),
LoadError::ChainHashMismatch {
index,
fact_id,
stored,
recomputed,
} => write!(
f,
"Fact[{}] (id={}) 链哈希不匹配: stored={stored}, recomputed={recomputed}",
index, fact_id.0
),
}
}
}
impl std::error::Error for LoadError {}
pub struct Auditor {
facts_log: FactsLog,
clock: LogicalClock,
last_audited_version: u64,
entries: Vec<AuditEntry>,
last_hash: String,
index: BTreeMap<FactId, usize>,
wal_path: Option<std::path::PathBuf>,
auto_verify: bool,
auto_verify_threshold: usize,
auto_verify_interval: usize,
audit_new_count: u64,
}
impl Auditor {
pub fn new(facts_log: FactsLog) -> Self {
Self {
facts_log,
clock: LogicalClock::new(),
last_audited_version: 0,
entries: Vec::new(),
last_hash: String::from("genesis"),
index: BTreeMap::new(),
wal_path: None,
auto_verify: false,
auto_verify_threshold: 1000,
auto_verify_interval: 1,
audit_new_count: 0,
}
}
pub fn new_with_auto_verify(
facts_log: FactsLog,
auto_verify: bool,
auto_verify_threshold: usize,
auto_verify_interval: usize,
) -> Self {
Self {
facts_log,
clock: LogicalClock::new(),
last_audited_version: 0,
entries: Vec::new(),
last_hash: String::from("genesis"),
index: BTreeMap::new(),
wal_path: None,
auto_verify,
auto_verify_threshold,
auto_verify_interval: if auto_verify_interval == 0 {
1
} else {
auto_verify_interval
},
audit_new_count: 0,
}
}
pub fn set_auto_verify(
&mut self,
auto_verify: bool,
auto_verify_threshold: usize,
auto_verify_interval: usize,
) {
self.auto_verify = auto_verify;
self.auto_verify_threshold = auto_verify_threshold;
self.auto_verify_interval = if auto_verify_interval == 0 {
1
} else {
auto_verify_interval
};
}
pub fn is_auto_verify_enabled(&self) -> bool {
self.auto_verify
}
#[deprecated(
since = "0.2.0",
note = "两套 WAL 合并:审计器不再独立写 WAL,请使用 tier1 FactsLog WAL"
)]
pub fn with_wal_path<P: AsRef<std::path::Path>>(mut self, path: P) -> Self {
self.wal_path = Some(path.as_ref().to_path_buf());
self
}
#[deprecated(since = "0.2.0", note = "两套 WAL 合并:审计器不再独立写 WAL")]
#[allow(dead_code)]
fn entry_to_json_line(entry: &AuditEntry) -> String {
serde_json::json!({
"fact_id": entry.fact_id.0,
"fact_type": entry.fact_type,
"logical_time": entry.logical_time,
"content_hash": entry.content_hash,
"prev_hash": entry.prev_hash,
"cause": entry.cause.map(|c| c.0),
})
.to_string()
}
#[deprecated(
since = "0.2.0",
note = "两套 WAL 合并:审计器不再独立写 WAL,哈希链由 tier1 WAL 维护"
)]
#[allow(dead_code)]
fn append_wal(&self, _entry: &AuditEntry) {
}
#[allow(clippy::cognitive_complexity)]
pub fn audit_new(&mut self) -> usize {
self.audit_new_count += 1;
let history = self.facts_log.history();
let start = self.entries.len();
if start >= history.len() {
self.last_audited_version = self.facts_log.version();
tracing::debug!(version = self.last_audited_version, "audit_new: 无新增事实");
return 0;
}
let count = history.len() - start;
for (idx_offset, fact) in history[start..].iter().enumerate() {
let fact_id = fact.id();
let fact_type = fact.type_name();
let logical_time = self.clock.tick();
let content_hash = match hash::fact_hash(fact) {
Ok(h) => h,
Err(e) => {
tracing::error!(
事实ID = ?fact_id,
事实类型 = %fact_type,
错误 = %e,
"审计器: 事实哈希计算失败,跳过损坏事实"
);
continue;
}
};
let prev_hash = self.last_hash.clone();
let cause = extract_cause(fact);
let combined = format!("{}{}", prev_hash, content_hash);
let new_hash = blake3::hash(combined.as_bytes()).to_hex().to_string();
self.last_hash = new_hash;
let entry_index = start + idx_offset;
self.index.insert(fact_id, entry_index);
self.entries.push(AuditEntry {
fact_id,
fact_type,
logical_time,
content_hash,
prev_hash,
cause,
});
}
self.last_audited_version = self.facts_log.version();
tracing::debug!(
audited = count,
version = self.last_audited_version,
"audit_new: 完成"
);
if self.should_auto_verify() {
if !self.verify() {
tracing::error!(
entries = self.entries.len(),
audit_new_count = self.audit_new_count,
"audit_new: 实时审计验证失败,审计链可能存在数据篡改或损坏"
);
} else {
tracing::debug!(entries = self.entries.len(), "audit_new: 实时审计验证通过");
}
}
count
}
fn should_auto_verify(&self) -> bool {
if !self.auto_verify || self.entries.is_empty() {
return false;
}
if self.auto_verify_threshold > 0 && self.entries.len() > self.auto_verify_threshold {
tracing::debug!(
entries = self.entries.len(),
threshold = self.auto_verify_threshold,
"实时审计验证: 条目数超过阈值,跳过验证"
);
return false;
}
if self.auto_verify_interval > 1
&& self.audit_new_count % (self.auto_verify_interval as u64) != 0
{
return false;
}
true
}
pub fn verify(&self) -> bool {
let mut prev_hash = String::from("genesis");
for entry in &self.entries {
if entry.prev_hash != prev_hash {
tracing::warn!(
fact_id = entry.fact_id.0,
"verify: 哈希链断裂,prev_hash 不匹配"
);
return false;
}
let combined = format!("{}{}", entry.prev_hash, entry.content_hash);
let recomputed = blake3::hash(combined.as_bytes()).to_hex().to_string();
prev_hash = recomputed;
}
tracing::debug!(entries = self.entries.len(), "verify: 审计链完整");
true
}
pub fn entries(&self) -> &[AuditEntry] {
&self.entries
}
pub fn last_hash(&self) -> &str {
&self.last_hash
}
pub fn report(&self) -> String {
let entries_json: Vec<serde_json::Value> = self
.entries
.iter()
.map(|e| {
serde_json::json!({
"fact_id": e.fact_id.0,
"fact_type": e.fact_type,
"logical_time": e.logical_time,
"content_hash": e.content_hash,
"prev_hash": e.prev_hash,
"cause": e.cause.map(|c| c.0),
})
})
.collect();
let report = serde_json::json!({
"last_audited_version": self.last_audited_version,
"entry_count": self.entries.len(),
"last_hash": self.last_hash,
"entries": entries_json,
});
serde_json::to_string_pretty(&report).unwrap_or_else(|_| String::from("{}"))
}
pub fn causal_chain(&self, fact_id: FactId) -> Vec<AuditEntry> {
let mut chain = Vec::new();
let mut current = Some(fact_id);
while let Some(cur_id) = current {
match self.index.get(&cur_id) {
Some(&i) => {
let entry = self.entries[i].clone();
current = entry.cause;
chain.push(entry);
}
None => {
tracing::warn!(fact_id = cur_id.0, "causal_chain: 追溯中断,未找到审计条目");
break;
}
}
}
chain
}
#[deprecated(
since = "0.2.0",
note = "两套 WAL 合并:请使用 load_from_tier1_wal 读取 tier1 WAL(带哈希验证)"
)]
pub fn load_from_wal(&mut self, path: &std::path::Path) -> std::io::Result<()> {
use std::io::BufRead;
if !path.exists() {
return Ok(());
}
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
for (idx, line) in reader.lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let parsed: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => {
tracing::warn!(line = idx, "load_from_wal: 跳过无效 JSON 行");
continue;
}
};
let fact_id_num = match parsed.get("fact_id").and_then(|v| v.as_u64()) {
Some(n) => n,
None => continue,
};
let fact_type = match parsed.get("fact_type").and_then(|v| v.as_str()) {
Some(s) => s,
None => continue,
};
let logical_time = parsed
.get("logical_time")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let content_hash = parsed
.get("content_hash")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let prev_hash = parsed
.get("prev_hash")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let cause = parsed.get("cause").and_then(|v| v.as_u64()).map(FactId);
let fact_id = FactId(fact_id_num);
let entry = AuditEntry {
fact_id,
fact_type: FACT_TYPE_STATIC_TABLE
.iter()
.copied()
.find(|t| *t == fact_type)
.unwrap_or("Unknown"),
logical_time,
content_hash: content_hash.clone(),
prev_hash: prev_hash.clone(),
cause,
};
self.index.insert(fact_id, self.entries.len());
self.entries.push(entry);
let combined = format!("{}{}", prev_hash, content_hash);
self.last_hash = blake3::hash(combined.as_bytes()).to_hex().to_string();
if logical_time > self.clock.current() {
while self.clock.current() < logical_time {
self.clock.tick();
}
}
}
self.wal_path = Some(path.to_path_buf());
Ok(())
}
pub fn load_from_tier1_wal(&mut self, path: &std::path::Path) -> Result<(), LoadError> {
use evorule_reactor::read_wal_with_hash;
let records = read_wal_with_hash(path).map_err(|e| LoadError::IoError(e.to_string()))?;
self.entries.clear();
self.index.clear();
self.last_hash = String::from("genesis");
self.last_audited_version = 0;
self.audit_new_count = 0;
let mut prev_hash = String::from("genesis");
let mut has_hash_fields = false;
let mut max_logical_time = 0u64;
for (idx, record) in records.iter().enumerate() {
let fact = &record.fact;
let fact_id = fact.id();
let fact_type = fact.type_name();
if record.chain_hash.is_some() {
has_hash_fields = true;
}
if let Some(stored_content) = &record.content_hash {
let recomputed = hash::fact_hash(fact)
.map_err(|e| LoadError::HashError(format!("fact[{}]: {}", idx, e)))?;
if stored_content != &recomputed {
return Err(LoadError::ContentHashMismatch {
index: idx,
fact_id,
stored: stored_content.clone(),
recomputed,
});
}
}
if let Some(stored_prev) = &record.prev_hash {
if stored_prev != &prev_hash {
return Err(LoadError::ChainBroken {
index: idx,
fact_id,
stored_prev: stored_prev.clone(),
expected_prev: prev_hash.clone(),
});
}
}
let content_hash = record
.content_hash
.clone()
.unwrap_or_else(|| hash::fact_hash(fact).unwrap_or_else(|_| String::new()));
let combined = format!("{}{}", prev_hash, content_hash);
let recomputed_chain = blake3::hash(combined.as_bytes()).to_hex().to_string();
if let Some(stored_chain) = &record.chain_hash {
if stored_chain != &recomputed_chain {
return Err(LoadError::ChainHashMismatch {
index: idx,
fact_id,
stored: stored_chain.clone(),
recomputed: recomputed_chain.clone(),
});
}
}
prev_hash = recomputed_chain.clone();
let logical_time = idx as u64 + 1;
let cause = extract_cause(fact);
let entry = AuditEntry {
fact_id,
fact_type: FACT_TYPE_STATIC_TABLE
.iter()
.copied()
.find(|t| *t == fact_type)
.unwrap_or("Unknown"),
logical_time,
content_hash,
prev_hash: if record.prev_hash.is_some() {
record.prev_hash.clone().unwrap_or_default()
} else {
String::from("genesis")
},
cause,
};
self.index.insert(fact_id, idx);
self.entries.push(entry);
if logical_time > max_logical_time {
max_logical_time = logical_time;
}
}
self.last_hash = if has_hash_fields {
records
.last()
.and_then(|r| r.chain_hash.clone())
.unwrap_or(prev_hash)
} else {
prev_hash
};
while self.clock.current() < max_logical_time {
self.clock.tick();
}
self.last_audited_version = records.len() as u64;
if !has_hash_fields {
tracing::warn!(
path = %path.display(),
"load_from_tier1_wal: 旧格式 WAL(无哈希字段),仅重建状态,未验证哈希链"
);
} else {
tracing::info!(
path = %path.display(),
entries = self.entries.len(),
last_hash = %self.last_hash,
"load_from_tier1_wal: 加载并验证审计链成功"
);
}
Ok(())
}
pub fn export(&self) -> String {
let entries_json: Vec<serde_json::Value> = self
.entries
.iter()
.map(|e| {
serde_json::json!({
"fact_id": e.fact_id.0,
"fact_type": e.fact_type,
"logical_time": e.logical_time,
"content_hash": e.content_hash,
"prev_hash": e.prev_hash,
"cause": e.cause.map(|c| c.0),
})
})
.collect();
let export = serde_json::json!({
"version": "1.0",
"last_hash": self.last_hash,
"last_audited_version": self.last_audited_version,
"entry_count": self.entries.len(),
"entries": entries_json,
});
serde_json::to_string_pretty(&export).unwrap_or_else(|_| String::from("{}"))
}
pub fn import(&mut self, json_str: &str) -> Result<(), String> {
let parsed: serde_json::Value =
serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {}", e))?;
let version = parsed
.get("version")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'version' field".to_string())?;
if version != "1.0" {
return Err(format!("Unsupported version: {} (expected 1.0)", version));
}
let entries_arr = parsed
.get("entries")
.and_then(|e| e.as_array())
.ok_or_else(|| "Missing 'entries' array".to_string())?;
self.entries.clear();
self.index.clear();
self.last_hash = String::from("genesis");
self.last_audited_version = 0;
self.audit_new_count = 0;
for (idx, entry_val) in entries_arr.iter().enumerate() {
let fact_id_num = entry_val
.get("fact_id")
.and_then(|v| v.as_u64())
.ok_or_else(|| format!("Entry {} missing 'fact_id'", idx))?;
let fact_type_str = entry_val
.get("fact_type")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("Entry {} missing 'fact_type'", idx))?;
let logical_time = entry_val
.get("logical_time")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let content_hash = entry_val
.get("content_hash")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let prev_hash = entry_val
.get("prev_hash")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let cause = entry_val.get("cause").and_then(|v| v.as_u64()).map(FactId);
let fact_type: &'static str = FACT_TYPE_STATIC_TABLE
.iter()
.copied()
.find(|t| *t == fact_type_str)
.unwrap_or("Unknown");
let entry = AuditEntry {
fact_id: FactId(fact_id_num),
fact_type,
logical_time,
content_hash,
prev_hash,
cause,
};
self.index.insert(entry.fact_id, idx);
self.entries.push(entry);
}
self.last_hash = parsed
.get("last_hash")
.and_then(|v| v.as_str())
.unwrap_or("genesis")
.to_string();
self.last_audited_version = parsed
.get("last_audited_version")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let max_logical_time = self
.entries
.iter()
.map(|e| e.logical_time)
.max()
.unwrap_or(0);
while self.clock.current() < max_logical_time {
self.clock.tick();
}
tracing::info!(
entries = self.entries.len(),
last_audited_version = self.last_audited_version,
"import: 审计链导入完成"
);
Ok(())
}
pub fn import_and_verify(&mut self, json_str: &str) -> Result<bool, String> {
self.import(json_str)?;
let valid = self.verify();
if !valid {
tracing::warn!(
entries = self.entries.len(),
"import_and_verify: 导入后审计链验证失败,数据可能已损坏"
);
}
Ok(valid)
}
pub fn export_compressed(&self) -> Result<Vec<u8>, String> {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let export_str = self.export();
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(export_str.as_bytes())
.map_err(|e| format!("Compression write error: {}", e))?;
encoder
.finish()
.map_err(|e| format!("Compression finish error: {}", e))
}
pub fn import_compressed(&mut self, compressed: &[u8]) -> Result<(), String> {
use flate2::read::GzDecoder;
use std::io::Read;
let mut decoder = GzDecoder::new(compressed);
let mut decompressed = String::new();
decoder
.read_to_string(&mut decompressed)
.map_err(|e| format!("Decompression error: {}", e))?;
self.import(&decompressed)
}
pub fn import_compressed_and_verify(&mut self, compressed: &[u8]) -> Result<bool, String> {
self.import_compressed(compressed)?;
let valid = self.verify();
if !valid {
tracing::warn!(
entries = self.entries.len(),
"import_compressed_and_verify: 导入后审计链验证失败"
);
}
Ok(valid)
}
}
const FACT_TYPE_STATIC_TABLE: &[&str] = &[
"StateTransition",
"Command",
"PayloadUpdate",
"IoRequest",
"IoResponse",
"ControlSignal",
"Error",
"Unknown",
];
fn extract_cause(fact: &Fact) -> Option<FactId> {
match fact {
Fact::StateTransition { cause, .. } => Some(*cause),
Fact::IoRequest { cause, .. } => Some(*cause),
Fact::IoResponse { request_id, .. } => Some(*request_id),
_ => None,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
use super::*;
use evorule_reactor::{Fact, FactId, FactsLog, IoType};
use evorule_tcb::JsonValue;
fn make_facts_log() -> FactsLog {
FactsLog::new()
}
#[test]
fn test_auditor_basic_chain() {
let log = make_facts_log();
let f0 = Fact::PayloadUpdate {
id: FactId(1),
path: "k1".into(),
value: JsonValue::string("v1"),
};
let id0 = f0.id();
log.append(f0).unwrap();
let f1 = Fact::StateTransition {
id: FactId(2),
cause: id0,
new_payload: JsonValue::empty_object(),
new_queue: vec![],
};
log.append(f1).unwrap();
let mut auditor = Auditor::new(log);
let n = auditor.audit_new();
assert_eq!(n, 2);
assert!(auditor.verify());
assert_eq!(auditor.entries().len(), 2);
}
#[test]
fn test_auditor_causal_chain() {
let log = make_facts_log();
let f0 = Fact::PayloadUpdate {
id: FactId(1),
path: "root".into(),
value: JsonValue::from(0i64),
};
let id0 = f0.id();
log.append(f0).unwrap();
let f1 = Fact::StateTransition {
id: FactId(2),
cause: id0,
new_payload: JsonValue::empty_object(),
new_queue: vec![],
};
let id1 = f1.id();
log.append(f1).unwrap();
let f2 = Fact::IoRequest {
id: FactId(3),
cause: id1,
io_type: IoType::http_get(),
params: JsonValue::empty_object(),
};
let id2 = f2.id();
log.append(f2).unwrap();
let mut auditor = Auditor::new(log);
auditor.audit_new();
let chain = auditor.causal_chain(id2);
assert_eq!(chain.len(), 3);
assert_eq!(chain[0].fact_id, id2);
assert_eq!(chain[1].fact_id, id1);
assert_eq!(chain[2].fact_id, id0);
}
#[test]
fn test_auditor_wal_persist_and_reload() {
let tmp =
std::env::temp_dir().join(format!("auditor_wal_test_{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&tmp);
{
let log = FactsLog::with_wal(&tmp).expect("create wal");
let f0 = Fact::PayloadUpdate {
id: FactId(1),
path: "a".into(),
value: JsonValue::from(1i64),
};
let f1 = Fact::PayloadUpdate {
id: FactId(2),
path: "b".into(),
value: JsonValue::from(2i64),
};
log.append(f0).unwrap();
log.append(f1).unwrap();
let mut auditor = Auditor::new(log);
let n = auditor.audit_new();
assert_eq!(n, 2);
assert!(auditor.verify());
}
{
let log = make_facts_log();
let mut auditor = Auditor::new(log);
auditor.load_from_tier1_wal(&tmp).expect("load tier1 wal");
assert_eq!(auditor.entries().len(), 2);
assert!(auditor.verify());
}
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_load_from_tier1_wal_detects_content_tamper() {
let tmp =
std::env::temp_dir().join(format!("auditor_tamper_test_{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&tmp);
{
let log = FactsLog::with_wal(&tmp).expect("create wal");
let f = Fact::PayloadUpdate {
id: FactId(1),
path: "a".into(),
value: JsonValue::from(42i64),
};
log.append(f).unwrap();
}
{
let content = std::fs::read_to_string(&tmp).expect("read wal");
let tampered = content.replace("42", "999");
std::fs::write(&tmp, tampered).expect("write tampered wal");
}
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let result = auditor.load_from_tier1_wal(&tmp);
assert!(result.is_err(), "篡改 Fact 内容应被检测到");
match result.unwrap_err() {
LoadError::ContentHashMismatch { .. } => {}
other => panic!("期望 ContentHashMismatch,得到: {other}"),
}
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_load_from_tier1_wal_detects_chain_break() {
let tmp =
std::env::temp_dir().join(format!("auditor_chain_break_{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&tmp);
{
let log = FactsLog::with_wal(&tmp).expect("create wal");
let f0 = Fact::PayloadUpdate {
id: FactId(1),
path: "a".into(),
value: JsonValue::from(1i64),
};
let f1 = Fact::PayloadUpdate {
id: FactId(2),
path: "b".into(),
value: JsonValue::from(2i64),
};
log.append(f0).unwrap();
log.append(f1).unwrap();
}
{
let content = std::fs::read_to_string(&tmp).expect("read wal");
let lines: Vec<&str> = content.lines().collect();
let tampered_line2 = lines[1].replace("\"prev_hash\":\"", "\"prev_hash\":\"tampered");
let tampered = format!("{}\n{}\n", lines[0], tampered_line2);
std::fs::write(&tmp, tampered).expect("write tampered wal");
}
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let result = auditor.load_from_tier1_wal(&tmp);
assert!(result.is_err(), "哈希链断裂应被检测到");
match result.unwrap_err() {
LoadError::ChainBroken { .. } | LoadError::ChainHashMismatch { .. } => {}
other => panic!("期望 ChainBroken 或 ChainHashMismatch,得到: {other}"),
}
let _ = std::fs::remove_file(&tmp);
}
#[test]
#[allow(deprecated)]
fn test_auditor_wal_nonexistent_file() {
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let result = auditor.load_from_wal(std::path::Path::new("/nonexistent/path/wal.jsonl"));
assert!(result.is_ok());
assert_eq!(auditor.entries().len(), 0);
}
#[test]
fn test_auditor_wal_incremental() {
let tmp =
std::env::temp_dir().join(format!("auditor_wal_incr_{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&tmp);
let log = FactsLog::with_wal(&tmp).expect("create wal");
let mut auditor = Auditor::new(log.clone());
let f1 = Fact::PayloadUpdate {
id: FactId(1),
path: "k1".into(),
value: JsonValue::string("v1"),
};
log.append(f1).unwrap();
assert_eq!(auditor.audit_new(), 1);
let f2 = Fact::PayloadUpdate {
id: FactId(2),
path: "k2".into(),
value: JsonValue::string("v2"),
};
log.append(f2).unwrap();
assert_eq!(auditor.audit_new(), 1);
let mut auditor2 = Auditor::new(make_facts_log());
auditor2.load_from_tier1_wal(&tmp).expect("load tier1 wal");
assert_eq!(auditor2.entries().len(), 2);
assert!(auditor2.verify());
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_auditor_empty() {
let log = make_facts_log();
let mut auditor = Auditor::new(log);
assert_eq!(auditor.audit_new(), 0);
assert!(auditor.verify());
assert_eq!(auditor.entries().len(), 0);
}
#[test]
fn test_auditor_incremental_idempotent() {
let log = make_facts_log();
let f = Fact::PayloadUpdate {
id: FactId(1),
path: "x".into(),
value: JsonValue::string("y"),
};
log.append(f).unwrap();
let mut auditor = Auditor::new(log);
assert_eq!(auditor.audit_new(), 1);
assert_eq!(auditor.audit_new(), 0);
assert_eq!(auditor.audit_new(), 0);
assert_eq!(auditor.entries().len(), 1);
}
#[test]
fn test_auditor_causal_chain_not_found() {
let log = make_facts_log();
let f = Fact::PayloadUpdate {
id: FactId(1),
path: "x".into(),
value: JsonValue::string("y"),
};
log.append(f).unwrap();
let mut auditor = Auditor::new(log);
auditor.audit_new();
let chain = auditor.causal_chain(FactId(999));
assert!(chain.is_empty());
}
#[test]
fn test_auditor_causal_chain_root() {
let log = make_facts_log();
let f = Fact::PayloadUpdate {
id: FactId(1),
path: "x".into(),
value: JsonValue::string("y"),
};
let id = f.id();
log.append(f).unwrap();
let mut auditor = Auditor::new(log);
auditor.audit_new();
let chain = auditor.causal_chain(id);
assert_eq!(chain.len(), 1);
assert_eq!(chain[0].fact_id, id);
assert!(chain[0].cause.is_none());
}
#[test]
fn test_auditor_report_format() {
let log = make_facts_log();
let f = Fact::PayloadUpdate {
id: FactId(1),
path: "k".into(),
value: JsonValue::string("v"),
};
log.append(f).unwrap();
let mut auditor = Auditor::new(log);
auditor.audit_new();
let report = auditor.report();
let parsed: serde_json::Value = serde_json::from_str(&report).unwrap();
assert_eq!(parsed["entry_count"], 1);
assert_eq!(parsed["entries"].as_array().unwrap().len(), 1);
assert!(parsed["last_hash"].is_string());
assert!(parsed["last_audited_version"].is_number());
}
#[test]
fn test_auditor_verify_empty_chain() {
let log = make_facts_log();
let auditor = Auditor::new(log);
assert!(auditor.verify());
}
#[test]
fn test_auditor_io_response_causal_link() {
let log = make_facts_log();
let req = Fact::IoRequest {
id: FactId(10),
cause: FactId(1),
io_type: IoType::http_get(),
params: JsonValue::empty_object(),
};
let req_id = req.id();
log.append(req).unwrap();
let resp = Fact::IoResponse {
id: FactId(11),
request_id: req_id,
result: JsonValue::string("ok"),
error: None,
};
let resp_id = resp.id();
log.append(resp).unwrap();
let mut auditor = Auditor::new(log);
auditor.audit_new();
let chain = auditor.causal_chain(resp_id);
assert_eq!(chain.len(), 2);
assert_eq!(chain[0].fact_id, resp_id);
assert_eq!(chain[1].fact_id, req_id);
}
#[test]
fn test_auditor_last_hash_genesis() {
let log = make_facts_log();
let auditor = Auditor::new(log);
let report = auditor.report();
let parsed: serde_json::Value = serde_json::from_str(&report).unwrap();
assert_eq!(parsed["last_hash"], "genesis");
}
#[test]
#[allow(deprecated)]
fn test_auditor_wal_with_invalid_lines() {
let tmp =
std::env::temp_dir().join(format!("auditor_wal_bad_{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&tmp);
use std::io::Write;
let mut file = std::fs::File::create(&tmp).unwrap();
writeln!(file, "not valid json").unwrap();
writeln!(
file,
r#"{{"fact_id": 5, "fact_type": "PayloadUpdate", "logical_time": 3, "content_hash": "abc", "prev_hash": "genesis", "cause": null}}"#
)
.unwrap();
writeln!(file).unwrap(); drop(file);
let log = make_facts_log();
let mut auditor = Auditor::new(log);
auditor.load_from_wal(&tmp).expect("load wal");
assert_eq!(auditor.entries().len(), 1);
assert_eq!(auditor.entries()[0].fact_id, FactId(5));
assert_eq!(auditor.entries()[0].fact_type, "PayloadUpdate");
let _ = std::fs::remove_file(&tmp);
}
#[test]
fn test_auto_verify_default_disabled() {
let log = make_facts_log();
let auditor = Auditor::new(log);
assert!(
!auditor.is_auto_verify_enabled(),
"auto_verify 默认应为 false"
);
}
#[test]
fn test_auto_verify_enabled() {
let log = make_facts_log();
let auditor = Auditor::new_with_auto_verify(log, true, 1000, 1);
assert!(auditor.is_auto_verify_enabled());
}
#[test]
fn test_auto_verify_runs_after_audit_new() {
let log = make_facts_log();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
log.append(Fact::StateTransition {
id: FactId(2),
cause: FactId(1),
new_payload: JsonValue::empty_object(),
new_queue: vec![],
})
.unwrap();
let mut auditor = Auditor::new_with_auto_verify(log, true, 1000, 1);
let n = auditor.audit_new();
assert_eq!(n, 2);
assert!(auditor.verify());
}
#[test]
fn test_auto_verify_threshold_skips_verification() {
let log = make_facts_log();
for i in 0..3 {
log.append(Fact::Command {
id: FactId(i as u64 + 1),
instruction: JsonValue::empty_object(),
})
.unwrap();
}
let mut auditor = Auditor::new_with_auto_verify(log, true, 2, 1);
auditor.audit_new();
assert_eq!(auditor.entries().len(), 3);
assert!(auditor.verify()); }
#[test]
fn test_auto_verify_threshold_zero_means_no_limit() {
let log = make_facts_log();
for i in 0..100 {
log.append(Fact::Command {
id: FactId(i as u64 + 1),
instruction: JsonValue::empty_object(),
})
.unwrap();
}
let mut auditor = Auditor::new_with_auto_verify(log, true, 0, 1);
auditor.audit_new();
assert_eq!(auditor.entries().len(), 100);
assert!(auditor.verify());
}
#[test]
fn test_auto_verify_interval_skips_intermediate_calls() {
let log = make_facts_log();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
let mut auditor = Auditor::new_with_auto_verify(log, true, 0, 3);
auditor.audit_new(); assert_eq!(auditor.audit_new_count, 1);
auditor
.facts_log
.append(Fact::Command {
id: FactId(2),
instruction: JsonValue::empty_object(),
})
.unwrap();
auditor.audit_new(); assert_eq!(auditor.audit_new_count, 2);
auditor
.facts_log
.append(Fact::Command {
id: FactId(3),
instruction: JsonValue::empty_object(),
})
.unwrap();
auditor.audit_new(); assert_eq!(auditor.audit_new_count, 3);
assert_eq!(auditor.entries().len(), 3);
}
#[test]
fn test_set_auto_verify_after_creation() {
let log = make_facts_log();
let mut auditor = Auditor::new(log);
assert!(!auditor.is_auto_verify_enabled());
auditor.set_auto_verify(true, 500, 10);
assert!(auditor.is_auto_verify_enabled());
}
#[test]
fn test_auto_verify_interval_zero_becomes_one() {
let log = make_facts_log();
let auditor = Auditor::new_with_auto_verify(log, true, 0, 0);
drop(auditor);
}
#[test]
fn test_auto_verify_empty_entries_skips() {
let log = make_facts_log();
let mut auditor = Auditor::new_with_auto_verify(log, true, 0, 1);
let n = auditor.audit_new();
assert_eq!(n, 0);
assert_eq!(auditor.entries().len(), 0);
assert!(auditor.verify());
}
#[test]
fn test_auto_verify_detects_corruption() {
let log = make_facts_log();
log.append(Fact::Command {
id: FactId(1),
instruction: JsonValue::empty_object(),
})
.unwrap();
log.append(Fact::Command {
id: FactId(2),
instruction: JsonValue::empty_object(),
})
.unwrap();
let mut auditor = Auditor::new_with_auto_verify(log, true, 0, 1);
auditor.audit_new();
auditor.entries[0].prev_hash = "tampered".to_string();
assert!(!auditor.verify());
}
fn build_auditor_with_entries() -> Auditor {
let log = make_facts_log();
let f0 = Fact::PayloadUpdate {
id: FactId(10),
path: "k1".into(),
value: JsonValue::string("v1"),
};
let id0 = f0.id();
log.append(f0).unwrap();
let f1 = Fact::StateTransition {
id: FactId(11),
cause: id0,
new_payload: JsonValue::empty_object(),
new_queue: vec![],
};
log.append(f1).unwrap();
let mut auditor = Auditor::new(log);
auditor.audit_new();
auditor
}
#[test]
fn test_export_format() {
let auditor = build_auditor_with_entries();
let json_str = auditor.export();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["version"], "1.0");
assert!(parsed["last_hash"].is_string());
assert_eq!(parsed["last_audited_version"], 2); assert_eq!(parsed["entry_count"], 2);
assert!(parsed["entries"].is_array());
assert_eq!(parsed["entries"].as_array().unwrap().len(), 2);
let e0 = &parsed["entries"][0];
assert_eq!(e0["fact_id"], 10);
assert_eq!(e0["fact_type"], "PayloadUpdate");
assert_eq!(e0["prev_hash"], "genesis");
assert!(e0["cause"].is_null());
}
#[test]
fn test_export_empty_auditor() {
let log = make_facts_log();
let auditor = Auditor::new(log);
let json_str = auditor.export();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["version"], "1.0");
assert_eq!(parsed["entry_count"], 0);
assert_eq!(parsed["entries"], serde_json::Value::Array(vec![]));
assert_eq!(parsed["last_hash"], "genesis");
}
#[test]
fn test_import_export_roundtrip() {
let auditor = build_auditor_with_entries();
let export_str = auditor.export();
let original_verify = auditor.verify();
assert!(original_verify);
let log2 = make_facts_log();
let mut imported = Auditor::new(log2);
let result = imported.import(&export_str);
assert!(result.is_ok());
assert_eq!(imported.entries().len(), 2);
assert_eq!(imported.entries()[0].fact_id, FactId(10));
assert_eq!(imported.entries()[1].fact_id, FactId(11));
assert_eq!(
imported.entries()[0].content_hash,
auditor.entries()[0].content_hash
);
assert_eq!(imported.entries()[0].prev_hash, "genesis");
assert_eq!(imported.last_hash, auditor.last_hash);
assert_eq!(imported.last_audited_version, auditor.last_audited_version);
assert!(imported.verify());
}
#[test]
fn test_import_version_check() {
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let bad_version = r#"{"version": "2.0", "entries": []}"#;
let result = auditor.import(bad_version);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unsupported version"));
}
#[test]
fn test_import_missing_version() {
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let no_version = r#"{"entries": []}"#;
let result = auditor.import(no_version);
assert!(result.is_err());
assert!(result.unwrap_err().contains("version"));
}
#[test]
fn test_import_missing_entries() {
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let no_entries = r#"{"version": "1.0"}"#;
let result = auditor.import(no_entries);
assert!(result.is_err());
assert!(result.unwrap_err().contains("entries"));
}
#[test]
fn test_import_invalid_json() {
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let result = auditor.import("not a valid json {{{");
assert!(result.is_err());
assert!(result.unwrap_err().contains("JSON parse error"));
}
#[test]
fn test_import_unknown_fact_type_becomes_unknown() {
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let import_data = r#"{
"version": "1.0",
"last_hash": "anyhash",
"last_audited_version": 1,
"entries": [
{
"fact_id": 99,
"fact_type": "SomeUnknownType",
"logical_time": 5,
"content_hash": "abc",
"prev_hash": "genesis",
"cause": null
}
]
}"#;
let result = auditor.import(import_data);
assert!(result.is_ok());
assert_eq!(auditor.entries().len(), 1);
assert_eq!(auditor.entries()[0].fact_type, "Unknown");
assert_eq!(auditor.entries()[0].fact_id, FactId(99));
}
#[test]
fn test_import_resets_state() {
let mut auditor = build_auditor_with_entries();
assert_eq!(auditor.entries().len(), 2);
let empty_export = r#"{
"version": "1.0",
"last_hash": "genesis",
"last_audited_version": 0,
"entries": []
}"#;
let result = auditor.import(empty_export);
assert!(result.is_ok());
assert_eq!(auditor.entries().len(), 0);
assert_eq!(auditor.last_hash, "genesis");
assert_eq!(auditor.last_audited_version, 0);
}
#[test]
fn test_import_and_verify_detects_corruption() {
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let corrupted = r#"{
"version": "1.0",
"last_hash": "fakehash",
"last_audited_version": 1,
"entries": [
{
"fact_id": 1,
"fact_type": "Command",
"logical_time": 1,
"content_hash": "hash1",
"prev_hash": "genesis",
"cause": null
},
{
"fact_id": 2,
"fact_type": "Command",
"logical_time": 2,
"content_hash": "hash2",
"prev_hash": "WRONG_PREV_HASH",
"cause": null
}
]
}"#;
let result = auditor.import_and_verify(corrupted);
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
fn test_import_preserves_causal_chain() {
let auditor = build_auditor_with_entries();
let export_str = auditor.export();
let original_chain = auditor.causal_chain(FactId(11));
assert_eq!(original_chain.len(), 2);
let log2 = make_facts_log();
let mut imported = Auditor::new(log2);
imported.import(&export_str).unwrap();
let chain = imported.causal_chain(FactId(11));
assert_eq!(chain.len(), 2);
assert_eq!(chain[0].fact_id, FactId(11));
assert_eq!(chain[1].fact_id, FactId(10));
}
#[test]
fn test_import_resets_audit_new_count() {
let mut auditor = build_auditor_with_entries();
auditor.audit_new(); assert_eq!(auditor.audit_new_count, 2);
let export_str = auditor.export();
let log2 = make_facts_log();
let mut imported = Auditor::new(log2);
imported.import(&export_str).unwrap();
assert_eq!(imported.audit_new_count, 0);
}
#[test]
fn test_export_compressed_returns_valid_gzip() {
let auditor = build_auditor_with_entries();
let compressed = auditor.export_compressed().unwrap();
assert!(compressed.len() >= 2);
assert_eq!(compressed[0], 0x1f);
assert_eq!(compressed[1], 0x8b);
}
#[test]
fn test_export_compressed_smaller_than_json() {
let log = make_facts_log();
for i in 0..100 {
log.append(Fact::Command {
id: FactId(i as u64 + 1),
instruction: JsonValue::empty_object(),
})
.unwrap();
}
let mut auditor = Auditor::new(log);
auditor.audit_new();
let json_size = auditor.export().len();
let compressed = auditor.export_compressed().unwrap();
let compressed_size = compressed.len();
assert!(
compressed_size < json_size,
"compressed {} should be < json {}",
compressed_size,
json_size
);
let ratio = compressed_size as f64 / json_size as f64;
assert!(ratio < 0.5, "compression ratio {} should be < 0.5", ratio);
}
#[test]
fn test_import_compressed_roundtrip() {
let auditor = build_auditor_with_entries();
let compressed = auditor.export_compressed().unwrap();
let original_verify = auditor.verify();
assert!(original_verify);
let log2 = make_facts_log();
let mut imported = Auditor::new(log2);
let result = imported.import_compressed(&compressed);
assert!(result.is_ok());
assert_eq!(imported.entries().len(), 2);
assert_eq!(imported.entries()[0].fact_id, FactId(10));
assert_eq!(imported.entries()[1].fact_id, FactId(11));
assert_eq!(imported.entries()[0].prev_hash, "genesis");
assert_eq!(imported.last_hash, auditor.last_hash);
assert!(imported.verify());
}
#[test]
fn test_import_compressed_invalid_gzip() {
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let bad_data = b"this is not gzip data at all";
let result = auditor.import_compressed(bad_data);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Decompression error"));
}
#[test]
fn test_import_compressed_empty_data() {
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let result = auditor.import_compressed(&[]);
assert!(result.is_err());
}
#[test]
fn test_import_compressed_and_verify_detects_corruption() {
let corrupted_json = r#"{
"version": "1.0",
"last_hash": "fakehash",
"last_audited_version": 1,
"entries": [
{
"fact_id": 1,
"fact_type": "Command",
"logical_time": 1,
"content_hash": "hash1",
"prev_hash": "genesis",
"cause": null
},
{
"fact_id": 2,
"fact_type": "Command",
"logical_time": 2,
"content_hash": "hash2",
"prev_hash": "WRONG",
"cause": null
}
]
}"#;
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(corrupted_json.as_bytes()).unwrap();
let corrupted_compressed = encoder.finish().unwrap();
let log = make_facts_log();
let mut auditor = Auditor::new(log);
let result = auditor.import_compressed_and_verify(&corrupted_compressed);
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
fn test_export_compressed_empty_auditor() {
let log = make_facts_log();
let auditor = Auditor::new(log);
let compressed = auditor.export_compressed().unwrap();
assert!(!compressed.is_empty());
let log2 = make_facts_log();
let mut imported = Auditor::new(log2);
imported.import_compressed(&compressed).unwrap();
assert_eq!(imported.entries().len(), 0);
assert_eq!(imported.last_hash, "genesis");
}
#[test]
fn test_compressed_preserves_causal_chain() {
let auditor = build_auditor_with_entries();
let compressed = auditor.export_compressed().unwrap();
let log2 = make_facts_log();
let mut imported = Auditor::new(log2);
imported.import_compressed(&compressed).unwrap();
let chain = imported.causal_chain(FactId(11));
assert_eq!(chain.len(), 2);
assert_eq!(chain[0].fact_id, FactId(11));
assert_eq!(chain[1].fact_id, FactId(10));
}
#[test]
fn test_compressed_roundtrip_large_chain() {
let log = make_facts_log();
log.append(Fact::PayloadUpdate {
id: FactId(1),
path: "root".into(),
value: JsonValue::from(0i64),
})
.unwrap();
for i in 1..500 {
log.append(Fact::IoRequest {
id: FactId(i as u64 + 1),
cause: FactId(i as u64),
io_type: evorule_reactor::IoType::http_get(),
params: JsonValue::empty_object(),
})
.unwrap();
}
let mut auditor = Auditor::new(log);
auditor.audit_new();
assert_eq!(auditor.entries().len(), 500);
let compressed = auditor.export_compressed().unwrap();
let log2 = make_facts_log();
let mut imported = Auditor::new(log2);
imported.import_compressed(&compressed).unwrap();
assert_eq!(imported.entries().len(), 500);
assert!(imported.verify());
let chain = imported.causal_chain(FactId(500));
assert_eq!(chain.len(), 500);
assert_eq!(chain[0].fact_id, FactId(500)); assert_eq!(chain[499].fact_id, FactId(1)); }
}