Skip to main content

wanning_core/
state.rs

1//! 闸的完整运行状态([`WanningState`]):闸 + 审计日志 + 时钟。
2//!
3//! 这是 demo / 未来 MCP server 实际持有的对象。职责只有一条:
4//! **每一条决策都必须先落审计,再落账本**(write-ahead)——审计写不进去,这笔消费
5//! 就不能发生。这样「崩溃后的世界」只会比实时状态**更严格**(多扣不会出现,少扣可能),
6//! 永远不会出现「花了钱却查无此账」。
7//!
8//! 回放([`WanningState::replay`]):从 WAL 逐行重建状态,用记录里的 ts 驱动注入时钟,
9//! 并**重算每一条决策**与记录对账;任何不一致立即 fail-closed 报错。回放是确定性的:
10//! 同一份 WAL 回放两遍,state hash 相同。
11
12use std::path::Path;
13use std::sync::Arc;
14
15use crate::clock::{MockClock, SharedClock, SystemClock};
16use crate::delegation::Delegation;
17use crate::error::CoreError;
18use crate::gate::{Gate, GateDecision};
19use crate::intent::SpendIntent;
20use crate::wal::{fnv1a_64, Wal, WalDecision, WalRecord};
21
22/// 闸 + 审计日志 + 时钟的运行时状态。
23#[derive(Debug)]
24pub struct WanningState {
25    gate: Gate,
26    wal: Option<Wal>,
27}
28
29impl WanningState {
30    /// 纯内存状态(无审计落盘)。回放与测试用。
31    pub fn new(clock: SharedClock) -> Self {
32        Self {
33            gate: Gate::new(clock),
34            wal: None,
35        }
36    }
37
38    /// 带审计落盘的状态。WAL 打开为追加模式,绝不截断。
39    pub fn with_wal(clock: SharedClock, wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
40        Ok(Self {
41            gate: Gate::new(clock),
42            wal: Some(Wal::open(wal_path)?),
43        })
44    }
45
46    /// 生产状态:系统时钟 + 审计落盘。
47    ///
48    /// **注意:不回放已有 WAL**——闸从空开始,只往后追加。适合「一次进程一次新账」
49    /// 的 demo 场景;长期服务重启要接续旧账,用 [`WanningState::live_resuming`]。
50    pub fn live(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
51        Self::with_wal(Arc::new(SystemClock), wal_path)
52    }
53
54    /// 断点续跑:先整体回放已有 WAL 对账(损坏/篡改/不一致 → fail-closed 拒启),
55    /// 再换回系统时钟、继续往**同一份 WAL** 追加。
56    ///
57    /// 长期服务(MCP server)重启时用它:账本、撤销、nonce 登记全部从审计接续,
58    /// 绝不带着一张空账本接着判——否则重启会把 nonce 洗白、把撤销掉的授权复活。
59    ///
60    /// 同一份 WAL 同时至多一个**活着的写进程**(`Wal::open` 自动持单写者锁):
61    /// 第二个进程 fail-closed 拒启(`CoreError::WalLocked`)。两个平台并挂同一份
62    /// WAL(`.mcp.json` + `.trae/mcp.json`)就是真实场景——并发双闸的内存账本
63    /// 互不知情,预算硬上限会被合力突破(实测见 `tests/single_writer.rs`)。
64    ///
65    /// 与 [`WanningState::replay`] 的区别:replay 冻结在「过去的世界」(注入时钟停在
66    /// 最后一条记录的 ts、不挂 WAL);本方法校验过后回到「现在的世界」(系统时钟,
67    /// 继续写审计)。
68    pub fn live_resuming(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
69        let path = wal_path.as_ref();
70        // 先开 WAL(不存在则创建;append-only,绝不截断)——空文件是合法起点。
71        let wal = Wal::open(path)?;
72        let resumed = Self::replay(path)?;
73        Ok(Self {
74            gate: resumed.gate.with_clock(Arc::new(SystemClock)),
75            wal: Some(wal),
76        })
77    }
78
79    pub fn gate(&self) -> &Gate {
80        &self.gate
81    }
82
83    pub fn wal_path(&self) -> Option<&Path> {
84        self.wal.as_ref().map(Wal::path)
85    }
86
87    /// WAL 当前行数;无 WAL 时为 None。审计证据的「WAL 偏移」即行号。
88    pub fn wal_line_count(&self) -> Option<u64> {
89        self.wal.as_ref().map(Wal::line_count)
90    }
91
92    /// 最近一次追加的 WAL 行号(1-based);无 WAL 时为 None。
93    pub fn last_wal_line(&self) -> Option<u64> {
94        self.wal_line_count()
95    }
96
97    /// 审计完整性链的链尾值(最后一条记录的链值;无 WAL 时为 None)。
98    ///
99    /// 对账证据之一:实时侧这个值,与读侧 [`read_verified`](crate::wal::read_verified)
100    /// 独立重算的链尾必须相等——逐行成链,改历史行而不重算后续整条链,当场现形。
101    pub fn audit_chain_tail(&self) -> Option<u64> {
102        self.wal.as_ref().map(Wal::chain_tail)
103    }
104
105    /// 注册委托:先确认必成,再写审计,再入闸(write-ahead)。
106    pub fn register_delegation(&mut self, delegation: Delegation) -> Result<(), CoreError> {
107        // 预检与 Gate::register_delegation 同一套规则;先确认「必然成功」,
108        // 保证审计记录永远不会描述一次没发生的注册。
109        delegation.validate()?;
110        if self.gate.delegation(&delegation.id).is_some() {
111            return Err(CoreError::DuplicateDelegation(delegation.id));
112        }
113        let record = WalRecord::RegisterDelegation {
114            ts: self.now(),
115            delegation: delegation.clone(),
116        };
117        if let Some(wal) = self.wal.as_mut() {
118            wal.append(&record)?;
119        }
120        self.gate.register_delegation(delegation)
121    }
122
123    /// 撤销委托(kill switch):先确认必成,再写审计,再撤销。
124    pub fn revoke(&mut self, delegation_id: &str) -> Result<(), CoreError> {
125        if self.gate.delegation(delegation_id).is_none() {
126            return Err(CoreError::UnknownDelegation(delegation_id.to_string()));
127        }
128        let record = WalRecord::Revoke {
129            ts: self.now(),
130            delegation_id: delegation_id.to_string(),
131        };
132        if let Some(wal) = self.wal.as_mut() {
133            wal.append(&record)?;
134        }
135        self.gate.revoke(delegation_id)
136    }
137
138    /// 判定一笔消费意图:evaluate → 写审计 → commit(write-ahead)。
139    ///
140    /// 返回闸的判定。注意失败语义:
141    /// - 审计写失败 → `Err`,**状态零变更**(这笔消费没有发生,也不能发生);
142    /// - 审计写成功但 commit 失败(理论不可达)→ `Err`,WAL 领先于账本,
143    ///   回放侧只会更严格,不会放水。
144    pub fn decide(&mut self, intent: &SpendIntent) -> Result<GateDecision, CoreError> {
145        // 时钟只读一次:评估、WAL 记录 ts、落地扣减(含速率窗口时刻)用同一 `now`。
146        // 若各读各的,跨秒边界时实时侧速率窗口时刻会漂离 WAL 记录 ts,回放对账
147        // 会把诚实账本误判为不一致——单次读是回放可重建的前提。
148        let ts = self.now();
149        let verdict = self.gate.evaluate_at(intent, ts);
150        let spent_after = match verdict {
151            // Allow 携带的就是「扣减后的累计消费」,直接取用,不重算。
152            GateDecision::Allow { budget_after_cents } => budget_after_cents,
153            GateDecision::Deny { .. } => self.gate.spent_cents(&intent.delegation_id).unwrap_or(0),
154        };
155        let record = WalRecord::Decide {
156            ts,
157            decision: match verdict {
158                GateDecision::Allow { .. } => WalDecision::Allow,
159                GateDecision::Deny { .. } => WalDecision::Deny,
160            },
161            delegation_id: intent.delegation_id.clone(),
162            intent: intent.clone(),
163            reason: verdict.deny_reason(),
164            budget_after_cents: spent_after,
165        };
166        if let Some(wal) = self.wal.as_mut() {
167            wal.append(&record)?;
168        }
169        match verdict {
170            GateDecision::Allow { budget_after_cents } => {
171                let after = self.gate.commit_at(intent, ts)?;
172                debug_assert_eq!(after, budget_after_cents);
173                Ok(GateDecision::Allow {
174                    budget_after_cents: after,
175                })
176            }
177            deny => Ok(deny),
178        }
179    }
180
181    /// 闸状态指纹(FNV-1a 64,非密码学,仅用于确定性对账)。
182    ///
183    /// 覆盖:委托集、账本、撤销集、nonce 登记集、策略运行时状态(W-27 速率
184    /// 窗口时刻与类目台账——随 commit 演化的状态必须进指纹,否则「速率窗口跨
185    /// 重启被洗掉」这类回放缺失对账不出来);全部按有序迭代序列化,
186    /// 因此「同一份 WAL 回放两遍 hash 必相同」由构造保证。
187    pub fn state_hash(&self) -> u64 {
188        let snapshot = serde_json::json!({
189            "delegations": self.gate.delegations().collect::<Vec<_>>(),
190            "spent_cents": self.gate.ledger().entries().collect::<Vec<_>>(),
191            "revoked": self.gate.revocations().iter().collect::<Vec<_>>(),
192            "used_nonces": self.gate.replay_registry().iter().collect::<Vec<_>>(),
193            "policy_states": self.gate.policy_states().collect::<Vec<_>>(),
194        });
195        fnv1a_64(snapshot.to_string().as_bytes())
196    }
197
198    fn now(&self) -> u64 {
199        self.gate.clock().now()
200    }
201
202    /// 从 WAL 回放重建状态(确定性;损坏行 / 完整性链断裂 / 对账不一致 → fail-closed)。
203    ///
204    /// 返回的状态:
205    /// - 时钟是被注入的 [`MockClock`],冻结在最后一条记录的 ts(回放是「过去的世界」,
206    ///   不适合继续判定新意图——要续,就重新 `live()` 开一个新 WAL);
207    /// - 未挂 WAL(回放不追加记录)。
208    pub fn replay(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
209        // 读回即验完整性链(seq/prev 逐行核),再逐行重算对账。
210        let records = crate::wal::read_verified(wal_path)?.records;
211        let clock = MockClock::new(0);
212        let mut state = WanningState::new(Arc::new(clock.clone()));
213        for (line_no, record) in records {
214            let record_ts = record.ts();
215            clock.set_now(record_ts);
216            match record {
217                WalRecord::RegisterDelegation { delegation, .. } => state
218                    .gate
219                    .register_delegation(delegation)
220                    .map_err(|e| CoreError::WalMismatch {
221                        line: line_no,
222                        message: format!("重放注册失败: {e}"),
223                    })?,
224                WalRecord::Revoke { delegation_id, .. } => state
225                    .gate
226                    .revoke(&delegation_id)
227                    .map_err(|e| CoreError::WalMismatch {
228                        line: line_no,
229                        message: format!("重放撤销失败: {e}"),
230                    })?,
231                WalRecord::Decide {
232                    decision,
233                    intent,
234                    reason,
235                    budget_after_cents,
236                    ..
237                } => {
238                    // 重算用记录自身的 ts(与 clock.set_now 同一时刻):速率窗口等
239                    // 依赖「判定时刻」的检查必须在记录 ts 上复现,绝不能看回放进程
240                    // 的真实时钟。
241                    let ts = record_ts;
242                    let verdict = state.gate.evaluate_at(&intent, ts);
243                    match (verdict, decision, reason) {
244                        (
245                            GateDecision::Allow {
246                                budget_after_cents: recomputed,
247                            },
248                            WalDecision::Allow,
249                            None,
250                        ) => {
251                            if recomputed != budget_after_cents {
252                                return Err(CoreError::WalMismatch {
253                                    line: line_no,
254                                    message: format!(
255                                        "放行记录的累计消费与重算不一致:记录 {budget_after_cents} / 重算 {recomputed}"
256                                    ),
257                                });
258                            }
259                            state.gate.commit_at(&intent, ts).map_err(|e| {
260                                CoreError::WalMismatch {
261                                    line: line_no,
262                                    message: format!("重放扣减失败: {e}"),
263                                }
264                            })?;
265                        }
266                        (
267                            GateDecision::Deny { reason: recomputed },
268                            WalDecision::Deny,
269                            Some(recorded_reason),
270                        ) if recomputed == recorded_reason => {
271                            // 拒绝:状态零变更,只需口径一致。
272                        }
273                        (verdict, decision, reason) => {
274                            return Err(CoreError::WalMismatch {
275                                line: line_no,
276                                message: format!(
277                                    "重算判定与记录不一致:重算 {verdict:?} / 记录 {decision:?} reason={reason:?}"
278                                ),
279                            });
280                        }
281                    }
282                }
283            }
284        }
285        Ok(state)
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::clock::{Clock, MockClock};
293    use crate::gate::DenyReason;
294
295    fn tmp_wal(tag: &str) -> std::path::PathBuf {
296        use std::sync::atomic::{AtomicU64, Ordering};
297        static SEQ: AtomicU64 = AtomicU64::new(0);
298        let dir = std::env::temp_dir().join("wanning-state-tests");
299        std::fs::create_dir_all(&dir).expect("建临时目录");
300        // pid + 原子序号 + 纳秒:裸 pid 跨轮运行会撞残留账本(W-21 教训,W-43b 轮补齐)。
301        let nanos = std::time::SystemTime::now()
302            .duration_since(std::time::UNIX_EPOCH)
303            .map(|d| d.as_nanos())
304            .unwrap_or(0);
305        dir.join(format!(
306            "{tag}-{}-{}-{nanos}.jsonl",
307            std::process::id(),
308            SEQ.fetch_add(1, Ordering::SeqCst)
309        ))
310    }
311
312    fn delegation() -> Delegation {
313        Delegation::new(
314            "d1",
315            "boss",
316            "claude-code",
317            1000,
318            1000,
319            2000,
320            "agent:claude-code",
321        )
322    }
323
324    /// 续跑测试专用:回放侧时钟停在记录 ts(如 1500),续跑后是真实「现在」——
325    /// 委托窗口必须同时覆盖两个世界(1500 之前生效、系统时钟下未过期)。
326    fn long_lived_delegation() -> Delegation {
327        Delegation::new(
328            "d1",
329            "boss",
330            "claude-code",
331            1000,
332            1000,
333            SystemClock.now().checked_add(86_400).expect("有效期溢出"),
334            "agent:claude-code",
335        )
336    }
337
338    fn intent(nonce: u64, amount_cents: u64) -> SpendIntent {
339        SpendIntent::new("d1", nonce, amount_cents, "jd:shop-1", "grocery", "测试")
340    }
341
342    #[test]
343    fn allow_and_deny_are_both_recorded() {
344        let path = tmp_wal("both");
345        let clock = MockClock::new(1500);
346        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
347        state.register_delegation(delegation()).expect("注册");
348
349        // 放行
350        let a = state.decide(&intent(1, 500)).expect("判定");
351        assert!(a.is_allow());
352        // 拒绝(超额)
353        let d = state.decide(&intent(2, 9000)).expect("判定");
354        assert_eq!(d.deny_reason(), Some(DenyReason::OverBudget));
355
356        let records = crate::wal::read_records(&path).expect("读回");
357        assert_eq!(records.len(), 3, "注册 + 放行 + 拒绝");
358        let (_, first_decide) = &records[1];
359        let (_, second_decide) = &records[2];
360        match (first_decide.kind(), first_decide.ts(), second_decide.kind()) {
361            ("decide", 1500, "decide") => {}
362            other => panic!("记录形状不符: {other:?}"),
363        }
364        // deny 记录在案(带 reason、不带 budget 变化)
365        let crate::wal::WalRecord::Decide {
366            decision,
367            reason,
368            budget_after_cents,
369            ..
370        } = second_decide
371        else {
372            panic!("第二条决策记录应是 Decide");
373        };
374        assert_eq!(*decision, WalDecision::Deny);
375        assert_eq!(*reason, Some(DenyReason::OverBudget));
376        assert_eq!(*budget_after_cents, 500, "拒绝不改账本,累计消费仍是 500");
377    }
378
379    #[test]
380    fn write_ahead_audit_failure_leaves_state_untouched() {
381        // 审计写不进去 → 消费不能发生(状态零变更)。
382        // 构造:占用目标路径为目录,使 WAL 打开即失败。
383        let dir = std::env::temp_dir().join("wanning-state-tests");
384        std::fs::create_dir_all(&dir).expect("建临时目录");
385        // 路径名同理带纳秒:上一轮残留的同名目录会让本轮占位失败。
386        let nanos = std::time::SystemTime::now()
387            .duration_since(std::time::UNIX_EPOCH)
388            .map(|d| d.as_nanos())
389            .unwrap_or(0);
390        let path = dir.join(format!("dir-as-wal-{nanos}.jsonl"));
391        std::fs::create_dir_all(&path).expect("占位为目录");
392
393        let err = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).unwrap_err();
394        assert!(matches!(err, CoreError::WalIo(_)), "{err}");
395    }
396
397    #[test]
398    fn replay_rebuilds_state_and_is_deterministic() {
399        let path = tmp_wal("replay");
400        let clock = MockClock::new(1500);
401        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
402        state.register_delegation(delegation()).expect("注册");
403        state.decide(&intent(1, 500)).expect("放行");
404        state.decide(&intent(2, 9000)).expect("超额拒");
405        state.decide(&intent(3, 100)).expect("再放行");
406        state.revoke("d1").expect("撤销");
407        state.decide(&intent(4, 100)).expect("撤销后拒");
408
409        let live_hash = state.state_hash();
410        assert_eq!(
411            state.gate().spent_cents("d1"),
412            Some(600),
413            "实时累计消费 = 500 + 100"
414        );
415
416        // 回放两遍,hash 必须一致且等于实时状态。
417        let replayed = WanningState::replay(&path).expect("回放");
418        let hash_once = replayed.state_hash();
419        let replayed_again = WanningState::replay(&path).expect("回放二遍");
420        let hash_twice = replayed_again.state_hash();
421
422        assert_eq!(hash_once, hash_twice, "回放两遍 hash 必相同(确定性)");
423        assert_eq!(hash_once, live_hash, "回放态必须与实时态完全一致");
424        assert_eq!(replayed.gate().spent_cents("d1"), Some(600));
425        assert!(replayed.gate().is_revoked("d1"));
426        assert!(
427            replayed
428                .gate()
429                .replay_registry()
430                .contains("agent:claude-code", 1),
431            "重放登记也必须被重建"
432        );
433        assert_eq!(replayed.wal_line_count(), None, "回放态不追加记录");
434    }
435
436    #[test]
437    fn replay_uses_recorded_ts_so_expiry_reproduces() {
438        // 实时判定依赖时钟;回放若用真实时钟,过期委托会判成 Expired 与记录不符。
439        // 这里验证回放按记录 ts 驱动,过期/未过期的判定都能精确复现。
440        let path = tmp_wal("expiry");
441        let clock = MockClock::new(1500);
442        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
443        state.register_delegation(delegation()).expect("注册");
444        state.decide(&intent(1, 100)).expect("放行");
445        clock.set_now(2000); // 推到过期
446        state.decide(&intent(2, 100)).expect("过期拒");
447
448        let replayed = WanningState::replay(&path).expect("回放");
449        assert_eq!(replayed.state_hash(), state.state_hash());
450    }
451
452    #[test]
453    fn replay_fails_closed_on_corrupted_line() {
454        let path = tmp_wal("replay-corrupt");
455        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
456        state.register_delegation(delegation()).expect("注册");
457        drop(state);
458        // 追加半行
459        use std::io::Write;
460        let mut f = std::fs::OpenOptions::new()
461            .append(true)
462            .open(&path)
463            .expect("开");
464        f.write_all(b"{\"kind\":\"decide\",\"ts\":1,\"dele\n")
465            .expect("追加坏行");
466        drop(f);
467
468        match WanningState::replay(&path) {
469            Err(CoreError::WalBadLine { line, .. }) => assert_eq!(line, 2),
470            other => panic!("应 fail-closed 报错,实际 {other:?}"),
471        }
472    }
473
474    #[test]
475    fn replay_fails_closed_when_record_disagrees_with_recomputation() {
476        // 手工构造一条与闸语义矛盾的记录:同一 nonce 两次「放行」。
477        let path = tmp_wal("replay-tampered");
478        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
479        state.register_delegation(delegation()).expect("注册");
480        state.decide(&intent(1, 100)).expect("放行");
481        drop(state);
482        // 篡改:把同 nonce 的第二次放行直接写进 WAL(实时闸根本不可能放行它)。
483        // 包裹形态与真实写入完全一致(seq 接续、prev = 前两行的链尾)——这正是链的
484        // 已知边界:尾行内容没有后继行引用,链验不住,靠回放重算(语义对账)抓住。
485        use std::io::Write;
486        let verified = crate::wal::read_verified(&path).expect("读已有历史");
487        let forged = crate::wal::WalLine {
488            seq: verified.records.len() as u64 + 1,
489            prev: verified.tail,
490            rec: WalRecord::Decide {
491                ts: 1500,
492                decision: WalDecision::Allow,
493                delegation_id: "d1".to_string(),
494                intent: intent(1, 100),
495                reason: None,
496                budget_after_cents: 200,
497            },
498        };
499        let mut f = std::fs::OpenOptions::new()
500            .append(true)
501            .open(&path)
502            .expect("开");
503        f.write_all(serde_json::to_string(&forged).unwrap().as_bytes())
504            .and_then(|()| f.write_all(b"\n"))
505            .expect("追加");
506        drop(f);
507
508        match WanningState::replay(&path) {
509            Err(CoreError::WalMismatch { line, message }) => {
510                assert_eq!(line, 3, "不一致要指到行");
511                assert!(message.contains("不一致"), "{message}");
512            }
513            other => panic!("篡改记录必须 fail-closed,实际 {other:?}"),
514        }
515    }
516
517    #[test]
518    fn audit_chain_tail_matches_independent_read_side_recompute() {
519        // 完整性链对账证据:实时链尾 == 读侧逐行独立重算的链尾(两条路径各算各的)。
520        let path = tmp_wal("chain-tail");
521        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
522        state.register_delegation(delegation()).expect("注册");
523        state.decide(&intent(1, 500)).expect("放行");
524        state.decide(&intent(2, 9000)).expect("超额拒");
525        state.revoke("d1").expect("撤销");
526
527        let live_tail = state.audit_chain_tail().expect("必有 WAL");
528        let verified = crate::wal::read_verified(&path).expect("读回验链");
529        assert_eq!(verified.tail, live_tail, "读侧独立重算链尾 == 实时链尾");
530        assert_eq!(
531            WanningState::replay(&path).expect("回放").state_hash(),
532            state.state_hash(),
533            "链验过后,回放对账照常成立"
534        );
535    }
536
537    #[test]
538    fn live_resuming_fails_closed_on_broken_chain() {
539        // 历史行被改(改的是不参与判定的 memo,语义对账抓不住)→ 链断 → 续跑拒启。
540        // 至少三行:被改行必须有后继行引用它的链值,尾行是链的已知边界。
541        let path = tmp_wal("resume-chain");
542        {
543            let mut state =
544                WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
545            state.register_delegation(delegation()).expect("注册");
546            state.decide(&intent(1, 100)).expect("放行");
547            state.decide(&intent(2, 9000)).expect("超额拒");
548        }
549        let mut lines = crate::wal::raw_lines(&path).expect("读 WAL");
550        let mut value: serde_json::Value = serde_json::from_str(&lines[1]).expect("行是 JSON");
551        value["rec"]["intent"]["memo"] = serde_json::json!("被改写的备注");
552        lines[1] = value.to_string();
553        std::fs::write(&path, lines.join("\n") + "\n").expect("重写 WAL");
554
555        match WanningState::live_resuming(&path) {
556            Err(CoreError::WalChainBroken { line, .. }) => {
557                assert_eq!(line, 3, "断链点 = 被改行的下一行(prev 对不上)")
558            }
559            other => panic!("链断裂必须拒启,实际 {other:?}"),
560        }
561    }
562
563    #[test]
564    fn state_hash_changes_when_state_changes() {
565        let path = tmp_wal("hash");
566        let clock = MockClock::new(1500);
567        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开");
568        state.register_delegation(delegation()).expect("注册");
569        let h0 = state.state_hash();
570        state.decide(&intent(1, 100)).expect("放行");
571        let h1 = state.state_hash();
572        state.revoke("d1").expect("撤销");
573        let h2 = state.state_hash();
574        assert_ne!(h0, h1, "扣减后 hash 必变");
575        assert_ne!(h1, h2, "撤销后 hash 必变");
576    }
577
578    #[test]
579    fn empty_wal_replays_to_empty_state() {
580        let path = tmp_wal("empty");
581        std::fs::write(&path, "").expect("写空文件");
582        let replayed = WanningState::replay(&path).expect("空 WAL 是合法状态");
583        assert_eq!(
584            replayed.state_hash(),
585            WanningState::new(Arc::new(MockClock::new(0))).state_hash()
586        );
587    }
588
589    // -----------------------------------------------------------------------
590    // 断点续跑(live_resuming):长期服务重启必须从审计接续,绝不带空账本接着判
591    // -----------------------------------------------------------------------
592
593    #[test]
594    fn live_resuming_carries_ledger_revocations_and_nonces() {
595        let path = tmp_wal("resume");
596        {
597            let clock = MockClock::new(1500);
598            let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开");
599            state
600                .register_delegation(long_lived_delegation())
601                .expect("注册");
602            state.decide(&intent(1, 500)).expect("放行");
603            state.decide(&intent(2, 100)).expect("再放行");
604            state.revoke("d1").expect("撤销");
605        } // drop:进程「重启」
606
607        let resumed = WanningState::live_resuming(&path).expect("续跑");
608        // 账本/撤销/nonce 全部接续。
609        assert_eq!(resumed.gate().spent_cents("d1"), Some(600));
610        assert!(resumed.gate().is_revoked("d1"), "撤销必须跨重启存活");
611        assert_eq!(
612            resumed.state_hash(),
613            WanningState::replay(&path).expect("回放").state_hash(),
614            "续跑态与回放态必须一致"
615        );
616        // 时钟已回到「现在」:系统时钟,而非回放的冻结时刻 1500。
617        assert!(
618            resumed.gate().clock().now() > 1_700_000_000,
619            "续跑必须用系统时钟,得到 {}",
620            resumed.gate().clock().now()
621        );
622
623        // 续跑后的闸照常判定,且继续写同一份 WAL:撤销态下新意图被拒、旧 nonce 重放
624        // 也被拒(闸口径 revoked 先于 replay,两条都落到拒),账本不动。
625        let mut resumed = resumed;
626        let deny = resumed.decide(&intent(3, 100)).expect("判定");
627        assert_eq!(deny.deny_reason(), Some(DenyReason::Revoked));
628        let replay_deny = resumed.decide(&intent(1, 100)).expect("判定");
629        assert_eq!(replay_deny.deny_reason(), Some(DenyReason::Revoked));
630        assert_eq!(resumed.gate().spent_cents("d1"), Some(600), "账本不动");
631        let records = crate::wal::read_records(&path).expect("读回");
632        assert_eq!(records.len(), 6, "注册+2 放行+撤销+续跑后 2 条拒绝");
633    }
634
635    #[test]
636    fn live_resuming_on_fresh_wal_starts_empty() {
637        let path = tmp_wal("resume-fresh");
638        let mut state = WanningState::live_resuming(&path).expect("新 WAL 直接续跑=空账开张");
639        assert_eq!(
640            state.state_hash(),
641            WanningState::replay(&path).expect("回放").state_hash()
642        );
643        state
644            .register_delegation(long_lived_delegation())
645            .expect("注册");
646        assert!(state.decide(&intent(1, 100)).expect("判定").is_allow());
647    }
648
649    #[test]
650    fn live_resuming_fails_closed_on_corrupted_wal() {
651        let path = tmp_wal("resume-corrupt");
652        {
653            let mut state =
654                WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
655            state.register_delegation(delegation()).expect("注册");
656        }
657        use std::io::Write;
658        let mut f = std::fs::OpenOptions::new()
659            .append(true)
660            .open(&path)
661            .expect("开");
662        f.write_all(b"{\"kind\":\"decide\",\"ts\":1,\"dele\n")
663            .expect("追加坏行");
664        drop(f);
665
666        match WanningState::live_resuming(&path) {
667            Err(CoreError::WalBadLine { line, .. }) => assert_eq!(line, 2),
668            other => panic!("审计损坏必须拒启,实际 {other:?}"),
669        }
670    }
671}