Skip to main content

wanning_core/
wal.rs

1//! 审计日志(WAL):append-only JSONL,每条记录一行。
2//!
3//! 四卖点的第 4 条(全程审计)就落在这里:哪个意图、闸怎么判、为什么、判完账本多少,
4//! 全部一行 JSON,append-only,**永不 truncate**。
5//!
6//! 落盘行 = 内容 + 完整性链(W-21 引入;旧格式是裸记录,不互通,见下):
7//!
8//! ```json
9//! {"seq":1,"prev":0,"rec":{"kind":"register_delegation","ts":1700000000,"delegation":{...}}}
10//! {"seq":2,"prev":144...,  "rec":{"kind":"revoke","ts":1700000001,"delegation_id":"d1"}}
11//! {"seq":3,"prev":99...,   "rec":{"kind":"decide","ts":1700000002,"decision":"allow",
12//!          "delegation_id":"d1","intent":{...},"budget_after_cents":500}}
13//! {"seq":4,"prev":77...,   "rec":{"kind":"decide","ts":1700000003,"decision":"deny",
14//!          "delegation_id":"d1","intent":{...},"reason":"over_budget","budget_after_cents":500}}
15//! ```
16//!
17//! `budget_after_cents` = 该决策落地后的**累计消费**(分),不是剩余预算;
18//! 剩余预算 = 委托 cap − 此值。选累计消费而不是剩余:对未知委托也能给出明确定义(0),
19//! 且回放重建账本时可直接对账。
20//!
21//! **完整性链(防篡改)**:每行带 `seq`(物理行号)与 `prev`(上一行的链值,首行 0),
22//! 链值 = FNV-1a64(`prev` 小端 8 字节 ‖ `seq` 小端 8 字节 ‖ 该行 `rec` 的规范 JSON)。
23//! 读回([`read_verified`])逐行验两件事:`seq` 必须等于物理行号(删行/重排/复制当场现形),
24//! `prev` 必须等于按前文重算的链值(改任何一行而不重算后续整条链,下一行的 `prev` 就对不上)
25//! ——任何一处不符即 fail-closed 报错。这只把日志从「可信因为约定 append-only」变成
26//! 「可信因为改了会被抓住」。
27//!
28//! **已知边界**(诚实声明,不假装能测):链抓不住「只改最后一行内容」与「整体截尾」——
29//! 最后一行没有后继行引用它,截尾剩下的前缀自身是一条合法的链。要堵住需要**外部锚点**
30//! (所有者侧签名的链尾 / 远端锚点),列为账户开通后的 TODO(见决策记录)。
31//!
32//! 回放([`read_verified`] + `crate::state::WanningState::replay`):逐行重放到一个空闸上,
33//! **重算结果必须与记录一致**,不一致即 fail-closed 报错;任何半行 JSON / 非法行 / 空行
34//! 同样报错,**绝不静默跳过**——审计日志宁可停,不可吞。打开续写([`Wal::open`])同样
35//! 先整体验一遍历史,带病审计绝不追加。
36//!
37//! 单写者:一份 WAL 同时至多一个**活着的写进程**([`WalLock`],[`Wal::open`] 自动持锁)。
38//! 两个都已活着的进程共写一份审计,各自内存账本只知道自己花了几笔 → 预算硬上限失效、
39//! 同一 nonce 跨进程放行,所以第二个写进程一律 fail-closed 拒启。锁只挡写进程,不挡读者:
40//! 回放/审计读取走只读打开,服务运行期间照常可用。
41
42use std::fs::{File, OpenOptions};
43use std::io::{BufRead, BufReader, Write};
44use std::path::{Path, PathBuf};
45
46use serde::{Deserialize, Serialize};
47
48use crate::delegation::Delegation;
49use crate::error::CoreError;
50use crate::gate::DenyReason;
51use crate::intent::SpendIntent;
52
53/// 决策结论(WAL 行内的小写蛇形字符串)。
54#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum WalDecision {
57    Allow,
58    Deny,
59}
60
61/// 审计日志的一行。
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(tag = "kind", rename_all = "snake_case")]
64pub enum WalRecord {
65    /// 用户授权:一份委托进入闸。
66    RegisterDelegation { ts: u64, delegation: Delegation },
67    /// 用户收权(kill switch)。
68    Revoke { ts: u64, delegation_id: String },
69    /// 闸的一次判定(放行与拒绝都记)。
70    Decide {
71        ts: u64,
72        decision: WalDecision,
73        delegation_id: String,
74        intent: SpendIntent,
75        /// 拒绝原因;Allow 时缺省(serde skip)。
76        #[serde(skip_serializing_if = "Option::is_none")]
77        reason: Option<DenyReason>,
78        /// 该决策落地后的累计消费(分),见模块注释。
79        budget_after_cents: u64,
80    },
81}
82
83impl WalRecord {
84    /// 记录时刻(Unix 秒)。回放用它驱动注入时钟,保证判定与实时一致。
85    pub fn ts(&self) -> u64 {
86        match self {
87            WalRecord::RegisterDelegation { ts, .. }
88            | WalRecord::Revoke { ts, .. }
89            | WalRecord::Decide { ts, .. } => *ts,
90        }
91    }
92
93    /// 记录种类(审计展示用)。
94    pub fn kind(&self) -> &'static str {
95        match self {
96            WalRecord::RegisterDelegation { .. } => "register_delegation",
97            WalRecord::Revoke { .. } => "revoke",
98            WalRecord::Decide { .. } => "decide",
99        }
100    }
101}
102
103/// 落盘的一行:内容(`rec`)+ 完整性链(`seq`/`prev`)。
104///
105/// 内容与链分开存:链字段本身不参与链值计算(否则自引用),链值只覆盖 `rec` 的
106/// 规范 JSON——所以改内容必断链,而 `seq`/`prev` 被改则直接对不上物理行号/前文。
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
108pub struct WalLine {
109    /// 物理行号(1-based)。读回时逐行核对,删行/重排/复制当场现形。
110    pub seq: u64,
111    /// 上一行的链值(首行为创世值 0)。
112    pub prev: u64,
113    /// 记录本体。
114    pub rec: WalRecord,
115}
116
117/// 完整性链值:FNV-1a64(`prev` 小端 8 字节 ‖ `seq` 小端 8 字节 ‖ `rec` 规范 JSON)。
118///
119/// 链值只吃 `rec` 的规范 JSON(`serde_json::to_string`),不吃整行原文——行内键序
120/// 差异、`seq`/`prev` 字段位置都不影响验证,同一内容重算恒等。
121/// `pub(crate)`:锚点(W-23)读侧独立重算链尾时复用同一口径,不另抄一份公式。
122pub(crate) fn chain_value(prev: u64, seq: u64, rec_json: &str) -> u64 {
123    let mut bytes = Vec::with_capacity(16 + rec_json.len());
124    bytes.extend_from_slice(&prev.to_le_bytes());
125    bytes.extend_from_slice(&seq.to_le_bytes());
126    bytes.extend_from_slice(rec_json.as_bytes());
127    fnv1a_64(&bytes)
128}
129
130/// FNV-1a 64(非密码学,确定性对账/完整性链用)。
131pub(crate) fn fnv1a_64(bytes: &[u8]) -> u64 {
132    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
133    const PRIME: u64 = 0x0000_0100_0000_01b3;
134    let mut hash = OFFSET_BASIS;
135    for byte in bytes {
136        hash ^= u64::from(*byte);
137        hash = hash.wrapping_mul(PRIME);
138    }
139    hash
140}
141
142/// append-only 日志句柄。打开即追加,绝不截断;**打开即持单写者锁**,
143/// **打开即验完整历史**(完整性链断裂 → 拒开,带病审计绝不续写)。
144#[derive(Debug)]
145pub struct Wal {
146    file: File,
147    path: PathBuf,
148    lines: u64,
149    /// 完整性链尾值(最后一条记录的链值;空日志为创世值 0)。
150    chain: u64,
151    /// 持有单写者锁(字段活着 = 锁在;Drop 时随句柄一起释放)。
152    _lock: WalLock,
153}
154
155/// WAL 对应的单写者锁文件路径:`<wal 文件名>.lock`,与 WAL 同目录。
156///
157/// 刻意用「追加后缀」而不是 `Path::with_extension`(那会把 `.jsonl` 整个换掉),
158/// 这样 `mcp-demo.wal → mcp-demo.wal.lock`、`a.jsonl → a.jsonl.lock` 一律成立。
159pub fn single_writer_lock_path(wal_path: impl AsRef<Path>) -> PathBuf {
160    let wal_path = wal_path.as_ref();
161    let mut name = wal_path
162        .file_name()
163        .map(|n| n.to_os_string())
164        .unwrap_or_default();
165    name.push(".lock");
166    wal_path.with_file_name(name)
167}
168
169/// 单写者锁:持锁期间同一份 WAL 只允许这一个写进程存在(fail-closed)。
170///
171/// **为什么必须有**:闸的账本、nonce 登记、撤销集合都在内存里,WAL 只在启动时
172/// 回放一次。两个都已活着的进程共写一份 WAL,各自内存账本只知道自己花了几笔——
173/// 实测(本仓 `tests/single_writer.rs`,修复前)两进程各放行 700 分、委托 cap
174/// 1000 分,合计 1400 分:预算硬上限失效,且 WAL 出现两行同一 id 的注册,
175/// 下次回放对账必炸。`.mcp.json` 与 `.trae/mcp.json` 指向同一份默认 WAL,
176/// 两个平台并挂就是真实场景。
177///
178/// **机制**(零依赖、跨平台):`create_new`(O_EXCL)原子创建锁文件——两个进程
179/// 同时抢,恰好一个成功;内容 = 持锁进程 PID + WAL 路径,供拒启方报错指认。
180/// 锁随 [`Drop for WalLock`](Self) 释放(正常退出/panic 展开都会走到)。
181///
182/// **已知权衡**(记录于决策记录):持锁进程被 kill -9 会留下孤儿锁,
183/// 下一个进程拒启,按错误信息确认无活进程后手动删除锁文件即可恢复(默认 WAL 在
184/// `target/` 下,`cargo clean` 亦可)。刻意不做「自动判死」:std 没有跨平台进程
185/// 存活检查,臆造判活逻辑比让所有者手删一行文件危险得多——审计闸宁可拒启,不可
186/// 带病放行。
187///
188/// **语义边界**:锁只挡写进程,不挡读者——回放/审计读取走只读打开,服务运行
189/// 期间照常可用(见 `tests/single_writer.rs::replay_works_while_writer_holds_lock`)。
190#[derive(Debug)]
191pub struct WalLock {
192    path: PathBuf,
193}
194
195impl WalLock {
196    /// 拿单写者锁;被占 → [`CoreError::WalLocked`](crate::error::CoreError::WalLocked)。
197    pub fn acquire(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
198        let wal_path = wal_path.as_ref();
199        let lock_path = single_writer_lock_path(wal_path);
200        match OpenOptions::new()
201            .write(true)
202            .create_new(true)
203            .open(&lock_path)
204        {
205            Ok(mut file) => {
206                let written = writeln!(file, "pid={}", std::process::id())
207                    .and_then(|()| writeln!(file, "wal={}", wal_path.display()))
208                    .and_then(|()| file.flush());
209                if let Err(e) = written {
210                    // 内容没写成 → 不留半个锁文件,锁没拿到就如实报 IO 错。
211                    let _ = std::fs::remove_file(&lock_path);
212                    return Err(CoreError::WalIo(format!(
213                        "写单写者锁 {lock_path:?} 失败(fail-closed): {e}"
214                    )));
215                }
216                Ok(Self { path: lock_path })
217            }
218            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
219                let holder = std::fs::read_to_string(&lock_path).unwrap_or_default();
220                let holder = holder.trim();
221                let holder_note = if holder.is_empty() {
222                    "锁文件为空(持锁方刚创建,极可能是并发启动竞争)".to_string()
223                } else {
224                    format!("持锁信息: {holder}")
225                };
226                Err(CoreError::WalLocked {
227                    path: lock_path.display().to_string(),
228                    message: format!(
229                        "同一份审计日志已有另一个 Wanning 进程在写({holder_note});\
230                         确认没有别的闸在跑后,删除该锁文件即可恢复\
231                         (默认 WAL 在 target/ 下,cargo clean 亦可)"
232                    ),
233                })
234            }
235            Err(e) => Err(CoreError::WalIo(format!(
236                "创建单写者锁 {lock_path:?} 失败: {e}"
237            ))),
238        }
239    }
240}
241
242impl Drop for WalLock {
243    fn drop(&mut self) {
244        // 尽力而为:锁文件删不掉不该让业务报错(比如已被人工清理)。
245        let _ = std::fs::remove_file(&self.path);
246    }
247}
248
249impl Wal {
250    /// 打开(不存在则创建)用于追加。**禁 truncate**:已有内容一律保留。
251    ///
252    /// 先自动创建父目录(W-43a 默认路径 `~/.wanning/wal.jsonl` 的「零配置」体验;
253    /// 显式路径同样受益),再拿单写者锁(fail-closed:第二个写进程拒启),再
254    /// **整体验一遍已有历史**([`read_verified`]:逐行可解析 + 完整性链——历史被
255    /// 改/删/排,拒开不续写),再以追加模式打开。锁定之后验历史,才不会和另一个
256    /// 进程的追加赛跑。
257    pub fn open(path: impl AsRef<Path>) -> Result<Self, CoreError> {
258        let path = path.as_ref().to_path_buf();
259        crate::paths::ensure_wal_parent(&path)?;
260        let _lock = WalLock::acquire(&path)?;
261        // 文件不存在 = 全新日志(0 行、创世链 0);存在则历史必须完整体面。
262        let (existing_lines, chain) = if path.exists() {
263            let verified = read_verified(&path)?;
264            (verified.records.len() as u64, verified.tail)
265        } else {
266            (0, 0)
267        };
268        let file = OpenOptions::new()
269            .create(true)
270            .append(true)
271            .read(false)
272            .open(&path)
273            .map_err(|e| CoreError::WalIo(format!("打开 WAL {path:?} 失败: {e}")))?;
274        Ok(Self {
275            file,
276            path,
277            lines: existing_lines,
278            chain,
279            _lock,
280        })
281    }
282
283    pub fn path(&self) -> &Path {
284        &self.path
285    }
286
287    /// 已写入行数(含历史行,1-based 下一条即 `line_count() + 1`)。
288    pub fn line_count(&self) -> u64 {
289        self.lines
290    }
291
292    /// 完整性链尾值(最后一条记录的链值;空日志为创世值 0)。
293    pub fn chain_tail(&self) -> u64 {
294        self.chain
295    }
296
297    /// 追加一条记录,返回其行号(1-based)。
298    ///
299    /// 每条写完立即 `flush`——审计必须先于一切下游动作落盘(真消费触发前的证据)。
300    /// 行号即 `seq`、链尾即 `prev`,与读回验证的口径一致。
301    pub fn append(&mut self, record: &WalRecord) -> Result<u64, CoreError> {
302        let seq = self.lines + 1;
303        let rec_json = serde_json::to_string(record)
304            .map_err(|e| CoreError::WalIo(format!("WAL 记录序列化失败: {e}")))?;
305        let mut line = serde_json::to_string(&WalLine {
306            seq,
307            prev: self.chain,
308            rec: record.clone(),
309        })
310        .map_err(|e| CoreError::WalIo(format!("WAL 记录序列化失败: {e}")))?;
311        line.push('\n');
312        let path = self.path.clone();
313        self.file
314            .write_all(line.as_bytes())
315            .and_then(|()| self.file.flush())
316            .map_err(|e| CoreError::WalIo(format!("写 WAL {path:?} 失败: {e}")))?;
317        self.lines = seq;
318        self.chain = chain_value(self.chain, seq, &rec_json);
319        Ok(self.lines)
320    }
321}
322
323/// 读原始行(供审计展示直接引用原文)。文件不存在 → 错误。
324pub fn raw_lines(path: impl AsRef<Path>) -> Result<Vec<String>, CoreError> {
325    let path = path.as_ref();
326    let file =
327        File::open(path).map_err(|e| CoreError::WalIo(format!("读 WAL {path:?} 失败: {e}")))?;
328    BufReader::new(file)
329        .lines()
330        .collect::<Result<Vec<_>, _>>()
331        .map_err(|e| CoreError::WalIo(format!("读 WAL {path:?} 失败: {e}")))
332}
333
334/// [`read_verified`] 的产出:逐行记录(1-based 行号)+ 完整性链尾值。
335#[derive(Clone, Debug, PartialEq, Eq)]
336pub struct VerifiedLog {
337    /// 逐行记录,`Vec` 下标 i 的行号 = i + 1。
338    pub records: Vec<(u64, WalRecord)>,
339    /// 逐行完整性链节(与 `records` 一一对应);审计展示用——让人能看见每一行的
340    /// `prev → 本行链值`,而不是只给一个无法核对的链尾。读侧各链节独立重算,
341    /// 不是照抄落盘行。
342    pub links: Vec<WalChainLink>,
343    /// 最后一条记录的链值;空日志为创世值 0。实时侧(`Wal::chain_tail`)与
344    /// 回放侧各算一份,两边相等是审计对账的证据之一。
345    pub tail: u64,
346}
347
348/// 一行记录的完整性链节(读侧独立重算结果)。
349#[derive(Clone, Copy, Debug, PartialEq, Eq)]
350pub struct WalChainLink {
351    /// 物理行号(1-based),与 [`WalLine::seq`](WalLine::seq) 验证口径一致。
352    pub seq: u64,
353    /// 本行记录的前行链值(首行为创世值 0)。
354    pub prev: u64,
355    /// 本行链值(链尾即最后一条的 value;空日志无链节、链尾为 0)。
356    pub value: u64,
357}
358
359/// 读出全部记录(**逐行验完整性链**),附行号(1-based)。
360///
361/// **fail-closed**,一处都不过就整体报错,绝不静默跳过——审计日志里出现
362/// 「看不懂的行」或「对不上链的行」本身就是事故,吞掉它等于伪造证据:
363/// - 半行 JSON / 空行 / 未知结构 → [`CoreError::WalBadLine`];
364/// - `seq` 与物理行号不符(删行/重排/复制的痕迹)→ [`CoreError::WalChainBroken`];
365/// - `prev` 与按前文重算的链值不符(某行内容被改而后续整条链未重算)
366///   → [`CoreError::WalChainBroken`]。
367///
368/// 已知边界见模块注释:只改最后一行内容、整体截尾,链抓不住(无后继行引用)。
369pub fn read_verified(path: impl AsRef<Path>) -> Result<VerifiedLog, CoreError> {
370    let mut records = Vec::new();
371    let mut links = Vec::new();
372    let mut chain = 0u64;
373    for (idx, line) in raw_lines(path)?.into_iter().enumerate() {
374        let line_no = idx as u64 + 1;
375        if line.trim().is_empty() {
376            return Err(CoreError::WalBadLine {
377                line: line_no,
378                message: "空行(WAL 不允许空行)".to_string(),
379            });
380        }
381        let parsed: WalLine = match serde_json::from_str(&line) {
382            Ok(parsed) => parsed,
383            Err(e) => return Err(parse_failure(line_no, &line, e)),
384        };
385        if parsed.seq != line_no {
386            return Err(CoreError::WalChainBroken {
387                line: line_no,
388                message: format!(
389                    "seq={} 与物理行号 {line_no} 不一致——删行/重排/复制的痕迹",
390                    parsed.seq
391                ),
392            });
393        }
394        if parsed.prev != chain {
395            return Err(CoreError::WalChainBroken {
396                line: line_no,
397                message: format!(
398                    "prev={} 与按前文重算的链值 {chain} 不符——本行或之前的行被改过,\
399                     且后续整条链未重算",
400                    parsed.prev
401                ),
402            });
403        }
404        let rec_json = serde_json::to_string(&parsed.rec).map_err(|e| CoreError::WalBadLine {
405            line: line_no,
406            message: format!("记录重序列化失败: {e}"),
407        })?;
408        chain = chain_value(chain, line_no, &rec_json);
409        records.push((line_no, parsed.rec));
410        links.push(WalChainLink {
411            seq: line_no,
412            prev: parsed.prev,
413            value: chain,
414        });
415    }
416    Ok(VerifiedLog {
417        records,
418        links,
419        tail: chain,
420    })
421}
422
423/// 解析失败 → 报错。旧格式(W-21 之前的裸记录)给出指名道姓的说明:
424/// 新旧格式不互通,报错要让人知道为什么读不懂,而不是一句泛泛的解析失败。
425fn parse_failure(line_no: u64, line: &str, error: serde_json::Error) -> CoreError {
426    let legacy_hint = if serde_json::from_str::<WalRecord>(line).is_ok() {
427        ";该行是 W-21 引入完整性链之前的旧格式(裸记录,无 seq/prev 完整性链)。\
428         新旧格式不互通:旧文件原样保留、绝不迁移改写;确认旧日志已留档后,\
429         可将其改名/移走,让闸从一份新日志重新开始"
430    } else {
431        ""
432    };
433    CoreError::WalBadLine {
434        line: line_no,
435        message: format!("JSON 解析失败: {error}{legacy_hint}"),
436    }
437}
438
439/// 读出全部记录,附行号(1-based)。[`read_verified`] 的薄封装(只取记录,不取链尾)。
440pub fn read_records(path: impl AsRef<Path>) -> Result<Vec<(u64, WalRecord)>, CoreError> {
441    Ok(read_verified(path)?.records)
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn tmp_path(tag: &str) -> PathBuf {
449        use std::sync::atomic::{AtomicU64, Ordering};
450        static SEQ: AtomicU64 = AtomicU64::new(0);
451        let dir = std::env::temp_dir().join("wanning-wal-tests");
452        std::fs::create_dir_all(&dir).expect("建临时目录");
453        // pid + 原子序号 + 纳秒:裸 pid 跨轮运行会撞残留账本(W-21 教训,W-43b 轮补齐)。
454        let nanos = std::time::SystemTime::now()
455            .duration_since(std::time::UNIX_EPOCH)
456            .map(|d| d.as_nanos())
457            .unwrap_or(0);
458        dir.join(format!(
459            "{tag}-{}-{}-{nanos}.jsonl",
460            std::process::id(),
461            SEQ.fetch_add(1, Ordering::SeqCst)
462        ))
463    }
464
465    fn sample_record(ts: u64) -> WalRecord {
466        WalRecord::Decide {
467            ts,
468            decision: WalDecision::Allow,
469            delegation_id: "d1".into(),
470            intent: SpendIntent::new("d1", 1, 500, "jd:shop-1", "grocery", "测试"),
471            reason: None,
472            budget_after_cents: 500,
473        }
474    }
475
476    #[test]
477    fn append_is_one_json_per_line_and_counts_lines() {
478        let path = tmp_path("append");
479        let mut wal = Wal::open(&path).expect("打开");
480        assert_eq!(wal.line_count(), 0);
481        assert_eq!(wal.chain_tail(), 0, "空日志链尾 = 创世值 0");
482        assert_eq!(wal.append(&sample_record(1)).expect("写"), 1);
483        assert_eq!(wal.append(&sample_record(2)).expect("写"), 2);
484        drop(wal);
485
486        let lines = raw_lines(&path).expect("读");
487        assert_eq!(lines.len(), 2);
488        assert!(!lines[0].ends_with('\n'), "行内不含换行");
489        let line: WalLine = serde_json::from_str(&lines[0]).expect("逐行可解析");
490        assert_eq!(line.seq, 1, "首行 seq = 物理行号");
491        assert_eq!(line.prev, 0, "首行 prev = 创世值 0");
492        assert_eq!(line.rec.ts(), 1);
493        assert_eq!(line.rec.kind(), "decide");
494        let second: WalLine = serde_json::from_str(&lines[1]).expect("逐行可解析");
495        assert_eq!(second.seq, 2);
496        assert_ne!(second.prev, 0, "第二行 prev 必须是第一行的链值");
497    }
498
499    #[test]
500    fn chain_tail_matches_independent_recompute_and_survives_reopen() {
501        // 写侧的链尾与读侧独立重算的链尾必须一致;重开(验完整历史)后续写,链必须接上。
502        let path = tmp_path("chain-tail");
503        let mut wal = Wal::open(&path).expect("打开");
504        for ts in 1..=3 {
505            wal.append(&sample_record(ts)).expect("写");
506        }
507        let live_tail = wal.chain_tail();
508        drop(wal);
509
510        let verified = read_verified(&path).expect("读回验链");
511        assert_eq!(verified.records.len(), 3);
512        assert_eq!(verified.tail, live_tail, "读侧重算链尾 == 写侧链尾");
513        assert_ne!(live_tail, 0, "三条记录后链尾非 0");
514
515        // 重开:验历史通过,链从旧尾接续;再写一条,seq/prev 接得上,读回仍验得过。
516        let mut wal = Wal::open(&path).expect("重开(历史完整)");
517        assert_eq!(wal.line_count(), 3);
518        assert_eq!(wal.chain_tail(), live_tail, "重开后链尾从历史接续");
519        wal.append(&sample_record(4)).expect("续写");
520        let verified = read_verified(&path).expect("续写后读回验链");
521        assert_eq!(verified.records.len(), 4);
522        assert_eq!(verified.tail, wal.chain_tail());
523    }
524
525    #[test]
526    fn read_verified_reports_per_line_chain_links() {
527        // 逐行链(审计回放页要人能看见每一行的 prev→本行链值):与记录一一对应,
528        // 首行 prev = 创世值 0,本行 prev = 前行链值,尾行链值 = 链尾。
529        let path = tmp_path("links");
530        let mut wal = Wal::open(&path).expect("打开");
531        for ts in 1..=4 {
532            wal.append(&sample_record(ts)).expect("写");
533        }
534        drop(wal);
535
536        let verified = read_verified(&path).expect("读回验链");
537        assert_eq!(
538            verified.links.len(),
539            verified.records.len(),
540            "逐行链与记录一一对应"
541        );
542        for (idx, link) in verified.links.iter().enumerate() {
543            assert_eq!(link.seq, idx as u64 + 1, "link.seq = 物理行号");
544            if idx == 0 {
545                assert_eq!(link.prev, 0, "首行 prev = 创世值 0");
546            } else {
547                assert_eq!(
548                    link.prev,
549                    verified.links[idx - 1].value,
550                    "本行 prev = 前行链值"
551                );
552            }
553        }
554        assert_eq!(
555            verified.links.last().map(|link| link.value),
556            Some(verified.tail),
557            "尾行链值 = 链尾"
558        );
559    }
560
561    #[test]
562    fn empty_wal_has_no_chain_links() {
563        let path = tmp_path("empty-links");
564        std::fs::write(&path, "").expect("写空文件");
565        let verified = read_verified(&path).expect("空文件是合法状态");
566        assert!(verified.links.is_empty(), "空日志无链节");
567    }
568
569    #[test]
570    fn empty_wal_verifies_to_genesis_chain() {
571        let path = tmp_path("empty-chain");
572        std::fs::write(&path, "").expect("写空文件");
573        let verified = read_verified(&path).expect("空文件是合法状态");
574        assert!(verified.records.is_empty());
575        assert_eq!(verified.tail, 0, "空日志链尾 = 创世值 0");
576    }
577
578    #[test]
579    fn open_is_append_only_never_truncates() {
580        let path = tmp_path("append-only");
581        {
582            let mut wal = Wal::open(&path).expect("打开");
583            wal.append(&sample_record(1)).expect("写");
584        }
585        {
586            let mut wal = Wal::open(&path).expect("重开不得截断");
587            assert_eq!(wal.line_count(), 1, "重开必须看到历史行");
588            wal.append(&sample_record(2)).expect("追加");
589        }
590        assert_eq!(raw_lines(&path).expect("读").len(), 2, "历史行必须保留");
591    }
592
593    #[test]
594    fn decide_record_roundtrip_shape() {
595        // 拒绝记录带 reason,放行记录不带(缺省),形状与模块注释一致。
596        let deny = WalRecord::Decide {
597            ts: 7,
598            decision: WalDecision::Deny,
599            delegation_id: "d1".into(),
600            intent: SpendIntent::new("d1", 2, 9000, "jd:shop-1", "x", ""),
601            reason: Some(DenyReason::OverBudget),
602            budget_after_cents: 500,
603        };
604        let json = serde_json::to_string(&deny).unwrap();
605        assert!(json.contains("\"kind\":\"decide\""));
606        assert!(json.contains("\"decision\":\"deny\""));
607        assert!(json.contains("\"reason\":\"over_budget\""));
608        let back: WalRecord = serde_json::from_str(&json).unwrap();
609        assert_eq!(back, deny);
610
611        let allow_json = serde_json::to_string(&sample_record(1)).unwrap();
612        assert!(!allow_json.contains("reason"), "Allow 不应带 reason 字段");
613    }
614
615    #[test]
616    fn read_records_fails_closed_on_half_line() {
617        let path = tmp_path("corrupt");
618        std::fs::write(&path, "{\"kind\":\"revoke\",\"ts\":1,\"deleg\n").expect("写坏行");
619        let err = read_records(&path).unwrap_err();
620        assert!(
621            matches!(err, CoreError::WalBadLine { line: 1, .. }),
622            "半行 JSON 必须 fail-closed 报错: {err:?}"
623        );
624    }
625
626    #[test]
627    fn read_records_fails_closed_on_blank_line() {
628        let path = tmp_path("blank");
629        std::fs::write(&path, "\n").expect("写空行");
630        let err = read_records(&path).unwrap_err();
631        assert!(
632            matches!(err, CoreError::WalBadLine { line: 1, .. }),
633            "{err:?}"
634        );
635    }
636
637    #[test]
638    fn read_records_fails_closed_on_unknown_shape() {
639        let path = tmp_path("unknown");
640        std::fs::write(&path, "{\"kind\":\"mystery\",\"ts\":1}\n").expect("写");
641        let err = read_records(&path).unwrap_err();
642        assert!(
643            matches!(err, CoreError::WalBadLine { line: 1, .. }),
644            "{err:?}"
645        );
646    }
647
648    #[test]
649    fn read_records_reports_failing_line_number() {
650        let path = tmp_path("line3");
651        let mut wal = Wal::open(&path).expect("打开");
652        wal.append(&sample_record(1)).expect("写");
653        wal.append(&sample_record(2)).expect("写");
654        drop(wal);
655        let mut content = raw_lines(&path).expect("读").join("\n");
656        content.push_str("\n{\"kind\":\"decide\",\"ts\":3\n");
657        std::fs::write(&path, content).expect("追加坏行");
658
659        match read_records(&path) {
660            Err(CoreError::WalBadLine { line, .. }) => assert_eq!(line, 3, "报错必须指到坏行"),
661            other => panic!("应报 WalBadLine,实际 {other:?}"),
662        }
663    }
664}