Skip to main content

evorule_cli/commands/
truncate_partial_line.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 EvoRule Project
3// This file is part of EvoRule, licensed under GNU Affero General Public License v3 or later.
4//! 受控恢复:将 WAL 文件截断至最后一条完整行(半行拒收场景的明确出路)
5//!
6//! 流程(默认干跑,`--confirm` 才落盘):
7//! 1. 读文件字节,定位最后一个换行符——其后若还有非空字节即为半行;
8//! 2. 对截断后前缀跑全链三查,输出前缀链验证报告(链尾/记录数);
9//! 3. `--confirm`:先写 `<file>.partial.bak` 备份(含被截断的半行),再以截断
10//!    前缀覆写原文件;干跑模式仅打印将执行的动作。
11//!
12//! 定位:审计链哨兵退出码 5 的处置指引。半行即崩溃窗口内的不完整写入,
13//! 截断不损失任何已确认事实(write-ahead 语义:完整行=已确认)。
14
15use std::path::{Path, PathBuf};
16
17use clap::Args;
18use evorule_reactor::{chain_step, fact_hash, read_wal_with_hash};
19
20use crate::error::CliError;
21
22#[derive(Debug, Args)]
23pub struct TruncatePartialLine {
24    /// WAL 文件(含半行)
25    pub wal_file: PathBuf,
26    /// 确认落盘(默认干跑:仅验证与打印)
27    #[arg(long)]
28    pub confirm: bool,
29}
30
31pub fn run(cmd: &TruncatePartialLine) -> Result<(), CliError> {
32    let path = &cmd.wal_file;
33    let data = std::fs::read(path).map_err(|e| CliError::other(format!("read: {e}")))?;
34
35    let last_nl = data
36        .iter()
37        .rposition(|&b| b == b'\n')
38        .ok_or_else(|| CliError::other("文件无完整行(无换行符),拒绝处理"))?;
39    let trailing = &data[last_nl + 1..];
40    if trailing.iter().all(|&b| b.is_ascii_whitespace()) || trailing.is_empty() {
41        println!(
42            "文件已以完整行结尾,无需截断(尾随空白 {} 字节属正常)",
43            trailing.len()
44        );
45        return Ok(());
46    }
47    let prefix = &data[..=last_nl];
48    println!(
49        "检测到半行:{} 字节不完整尾段将被截断(前缀保留 {} 字节 / {} 行)",
50        trailing.len(),
51        prefix.len(),
52        prefix.iter().filter(|&&b| b == b'\n').count()
53    );
54
55    // 前缀全链验证(复用哨兵同口径三查)
56    let tmp = std::env::temp_dir().join(format!(
57        "evorule_truncate_prefix_{}_{}",
58        std::process::id(),
59        path.file_name()
60            .map(|n| n.to_string_lossy().to_string())
61            .unwrap_or_default()
62    ));
63    std::fs::write(&tmp, prefix).map_err(|e| CliError::other(format!("temp write: {e}")))?;
64    let verify = (|| -> Result<(usize, String), String> {
65        let records = read_wal_with_hash(&tmp).map_err(|e| e.to_string())?;
66        let mut prev = "genesis".to_string();
67        for rec in &records {
68            let content = fact_hash(&rec.fact).map_err(|e| e.to_string())?;
69            if let Some(stored) = &rec.content_hash {
70                if stored != &content {
71                    return Err(format!("content_hash mismatch at fact {}", rec.fact.id().0));
72                }
73            }
74            if let Some(stored_prev) = &rec.prev_hash {
75                if stored_prev != &prev {
76                    return Err(format!("chain broken at fact {}", rec.fact.id().0));
77                }
78            }
79            let chain = chain_step(&prev, &content);
80            if let Some(stored_chain) = &rec.chain_hash {
81                if stored_chain != &chain {
82                    return Err(format!("chain_hash mismatch at fact {}", rec.fact.id().0));
83                }
84            }
85            prev = chain;
86        }
87        Ok((records.len(), prev))
88    })();
89    let _ = std::fs::remove_file(&tmp);
90    match verify {
91        Ok((n, tail)) => println!(
92            "前缀链验证 PASS:{n} 条记录,链尾 {}…",
93            &tail[..16.min(tail.len())]
94        ),
95        Err(e) => {
96            return Err(CliError::other(format!(
97                "前缀链验证 FAIL(截断无济于事,文件存在更早破坏): {e}"
98            )))
99        }
100    }
101
102    if !cmd.confirm {
103        println!(
104            "干跑模式(未落盘)。确认后执行:evorule truncate-partial-line {} --confirm",
105            path.display()
106        );
107        return Ok(());
108    }
109
110    // 备份(含半行的原始文件)→ 覆写截断前缀
111    let bak = backup_path(path);
112    std::fs::write(&bak, &data).map_err(|e| CliError::other(format!("backup write: {e}")))?;
113    std::fs::write(path, prefix).map_err(|e| CliError::other(format!("truncate write: {e}")))?;
114    println!(
115        "已截断:{}(备份:{},含被截断半行,供取证)",
116        path.display(),
117        bak.display()
118    );
119    Ok(())
120}
121
122fn backup_path(p: &Path) -> PathBuf {
123    let mut s = p.as_os_str().to_os_string();
124    s.push(".partial.bak");
125    PathBuf::from(s)
126}