fix(core): 规则结论改在脱敏前算,会话路上 fabric.loader_0_12 能命中(M241)

会话 JSONL 是写前脱敏的,而脱敏的「≥64 无点号不透明串」是有损的:
fabric.loader_0_12 唯一的证据行(Sponge mixin 类路径 66 字符)被抹成
<redacted:opaque>,规则在会话路上永不命中。

修法不动脱敏策略、也不往盘上多写一份原文:
1. SessionWriter 在内存里留一个 2000 行的原始尾窗(不落盘);
2. 收尾时把这些**原始**事实交给新的 diagnose::session::conclude 跑规则,
   产出契约形状的 diagnosis payload —— 它落盘前仍过 Redactor::scrub_json;
3. 日志页优先读这条落盘结论,老会话才回落到「在脱敏文本上重跑规则」。

为什么不是「原文与脱敏文本并存」:会话 JSONL 里存原文会踩
core/tests/launch/pipeline_cases.rs 早已钉死的「事件流不得含凭据明文」。

顺带把 EventKind 纯搬移到 launch/session/event_kind.rs 守 400 行上限。
This commit is contained in:
AskaEth committed 2026-11-11 09:19:42 +08:00
1 parent 74be510114
commit edd6402fd5
10 files changed
+633 -65

No files matched your search

+47 -55
View File
@@ -3,9 +3,13 @@
//! schema 见 `DevDocs/SubAgentsTalk/launch-接口约定.md` §2/§3(v1)——M15 诊断采集器与
//! M16 `read_game_log`/`list_game_sessions` 消费同一份文件,是**单一真相源**。
//!
//! 两条纪律写死在实现里:
//! 三条纪律写死在实现里:
//! 1. 脱敏在写入前做([`Redactor`]),`cmd_built.argv` 也不例外(命令行必含 accessToken);
//! 2. 「launch 报事实、M15 下结论」:`diagnosis` 事件由 M15 产出、由本模块落盘。
//! 2. 「launch 报事实、M15 下结论」:`diagnosis` 事件由 M15 产出、由本模块落盘;
//! 3. **规则结论在脱敏前算**(M241):盘上只有脱敏文本,而脱敏的有损规则会把证据行也抹掉
//! (`fabric.loader_0_12` 的类路径就被 `≥64` 那条吃掉)。所以原始行在内存里留一个有界尾窗
//! (`session/raw_window.rs`),收尾时交给 [`crate::diagnose::session::conclude`] 算一次,
//! 落盘的仍然只有**结论**——它自己还要再走一遍 `scrub_json`。
use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
@@ -14,6 +18,7 @@ use std::path::{Path, PathBuf};
use serde::Serialize;
use serde_json::Value;
use crate::diagnose::session::{self as diagnosis, RawFacts};
use crate::launch::args_table::AssembledCommand;
use crate::launch::error::{AssemblyError, LaunchError, Result};
use crate::launch::exit_analysis::ExitAnalysis;
@@ -28,60 +33,12 @@ use crate::launch::window_detect::Detection;
/// 事件 schema 版本。
pub const SCHEMA_VERSION: u8 = 1;
/// 事件类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventKind {
/// 会话开始。
SessionStart,
/// Java 选择结果。
JavaSelected,
/// 命令行组装完成。
CmdBuilt,
/// natives 解压结果。
NativesExtracted,
/// 启动前失败(没有进程)。
AssemblyFailed,
/// 进程已启动。
ProcStart,
/// stdout 行。
Stdout,
/// stderr 行。
Stderr,
/// 检测到游戏窗口。
WindowDetected,
/// 进程退出。
Exit,
/// 发现崩溃报告 / hs_err。
CrashFound,
/// 诊断结论(M15 产出)。
Diagnosis,
/// 被主动结束。
Killed,
/// 会话结束。
SessionEnd,
}
#[path = "session/event_kind.rs"]
mod event_kind;
#[path = "session/raw_window.rs"]
mod raw_window;
impl EventKind {
/// JSONL 里的 `kind` 字符串。
pub fn as_str(self) -> &'static str {
match self {
Self::SessionStart => "session_start",
Self::JavaSelected => "java_selected",
Self::CmdBuilt => "cmd_built",
Self::NativesExtracted => "natives_extracted",
Self::AssemblyFailed => "assembly_failed",
Self::ProcStart => "proc_start",
Self::Stdout => "stdout",
Self::Stderr => "stderr",
Self::WindowDetected => "window_detected",
Self::Exit => "exit",
Self::CrashFound => "crash_found",
Self::Diagnosis => "diagnosis",
Self::Killed => "killed",
Self::SessionEnd => "session_end",
}
}
}
pub use event_kind::EventKind;
/// 会话结束原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -144,6 +101,10 @@ pub struct SessionWriter {
exit_type: Option<String>,
silent_failure: bool,
diagnosis_top: Option<String>,
/// 是否值得跑诊断(与 `ExitAnalysis::needs_diagnosis` 同值)。
needs_diagnosis: bool,
/// 原始行尾窗(诊断用;**不落盘**,见模块文档第 3 条)。
raw: raw_window::RawWindow,
finished: bool,
}
@@ -196,6 +157,8 @@ impl SessionWriter {
exit_type: None,
silent_failure: false,
diagnosis_top: None,
needs_diagnosis: false,
raw: raw_window::RawWindow::default(),
finished: false,
})
}
@@ -281,11 +244,14 @@ impl SessionWriter {
}
/// `stdout` / `stderr` 行事件。
///
/// 原始文本先进内存尾窗(诊断用),落盘的是脱敏后的那份([`Redactor`])。
pub fn line(&mut self, line: &LogLine) -> Result<()> {
let kind = match line.stream {
Stream::Stdout => EventKind::Stdout,
Stream::Stderr => EventKind::Stderr,
};
self.raw.push(line);
let text = self.redactor.scrub(&line.text).into_owned();
let payload = payload::log_line(&text, line.level());
self.record(kind, payload)
@@ -314,6 +280,7 @@ impl SessionWriter {
self.exit_code = Some(analysis.code);
self.exit_type = Some(analysis.exit_type.as_str().to_string());
self.silent_failure = analysis.silent_failure.is_some();
self.needs_diagnosis = analysis.needs_diagnosis;
let payload = payload::exit(analysis);
self.record(EventKind::Exit, payload)
}
@@ -354,9 +321,34 @@ impl SessionWriter {
self.record(EventKind::Diagnosis, record)
}
/// 用**原始行**补一次规则结论(写失败不打断收尾:launch 另有 `event_write_errors` 收集)。
///
/// 门槛与 launch 的挂钩一致(`ExitAnalysis::needs_diagnosis`):干净退出不跑引擎,也不写空事件。
fn conclude_from_raw(&mut self, duration_ms: u64) {
if !self.needs_diagnosis || self.diagnosis_top.is_some() {
return;
}
let (stdout, stderr) = self.raw.facts();
let facts = RawFacts {
exit_code: self.exit_code,
duration_ms,
error_lines: self.raw.error_lines(),
stdout: &stdout,
stderr: &stderr,
};
if let Some(record) = diagnosis::conclude(&facts) {
let _ = self.diagnosis(record);
}
}
/// `session_end` + 追加索引,返回索引条目。
///
/// 收尾前先补一次**原始行**上的规则结论(模块文档第 3 条):挂上真实 `DiagnosisHook` 时
/// 它已经写过结论(`diagnosis_top` 非空),这里不重复;没挂(今天的 `NoopDiagnosis`)就由
/// 这里补位,免得读回 JSONL 时只剩脱敏文本可看。
pub fn finish(&mut self, reason: EndReason) -> Result<SessionIndexEntry> {
let duration_ms = self.clock.elapsed_ms();
self.conclude_from_raw(duration_ms);
let payload = payload::session_end(reason.as_str(), duration_ms);
self.record(EventKind::SessionEnd, payload)?;
self.finished = true;
@@ -0,0 +1,58 @@
//! 会话事件类型(从 `session.rs` 纯搬移,守单文件 400 行上限)。
//!
//! 对外路径不变:`unlucky_core::launch::session::EventKind`。
/// 事件类型。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventKind {
/// 会话开始。
SessionStart,
/// Java 选择结果。
JavaSelected,
/// 命令行组装完成。
CmdBuilt,
/// natives 解压结果。
NativesExtracted,
/// 启动前失败(没有进程)。
AssemblyFailed,
/// 进程已启动。
ProcStart,
/// stdout 行。
Stdout,
/// stderr 行。
Stderr,
/// 检测到游戏窗口。
WindowDetected,
/// 进程退出。
Exit,
/// 发现崩溃报告 / hs_err。
CrashFound,
/// 诊断结论(M15 产出)。
Diagnosis,
/// 被主动结束。
Killed,
/// 会话结束。
SessionEnd,
}
impl EventKind {
/// JSONL 里的 `kind` 字符串。
pub fn as_str(self) -> &'static str {
match self {
Self::SessionStart => "session_start",
Self::JavaSelected => "java_selected",
Self::CmdBuilt => "cmd_built",
Self::NativesExtracted => "natives_extracted",
Self::AssemblyFailed => "assembly_failed",
Self::ProcStart => "proc_start",
Self::Stdout => "stdout",
Self::Stderr => "stderr",
Self::WindowDetected => "window_detected",
Self::Exit => "exit",
Self::CrashFound => "crash_found",
Self::Diagnosis => "diagnosis",
Self::Killed => "killed",
Self::SessionEnd => "session_end",
}
}
}
@@ -0,0 +1,59 @@
//! 原始行尾窗(M241):诊断要在**脱敏前**的文本上跑,落盘仍然只有脱敏文本。
//!
//! 会话事件流是写前脱敏的,脱敏的两条形状规则有损(见 [`crate::diagnose::session`] 的模块
//! 文档)。所以边界是:原始行**只在本进程内存里活到会话收尾**,收尾时算一次规则结论
//! (`diagnose::session::conclude`),写进 JSONL 的是结论本体,仍然过 `Redactor::scrub_json`。
//!
//! 容量按展示侧 `DEFAULT_TAIL` 同量级取:规则引擎看的是最近的日志,不是开局那几行;
//! 错误行数另外全量统计(`exit0 静默失败` 那类判据要的是总数,不是尾窗里的数)。
use std::collections::VecDeque;
use crate::launch::pump::LogLine;
/// 保留多少行原始文本(尾窗)。
pub const CAPACITY: usize = 2000;
/// 原始行尾窗 + 全量错误行计数。
#[derive(Debug, Default)]
pub struct RawWindow {
lines: VecDeque<(bool, String)>,
errors: u32,
}
impl RawWindow {
/// 记一行**原始**(未脱敏)文本。
pub fn push(&mut self, line: &LogLine) {
if line.level().is_error() {
self.errors = self.errors.saturating_add(1);
}
if self.lines.len() >= CAPACITY {
self.lines.pop_front();
}
self.lines
.push_back((line.stream.is_stderr(), line.text.clone()));
}
/// 全量错误行数。
pub fn error_lines(&self) -> u32 {
self.errors
}
/// 尾窗按流拆开:`(stdout, stderr)`,各自按时间顺序。
pub fn facts(&self) -> (Vec<String>, Vec<String>) {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
for (is_stderr, text) in &self.lines {
if *is_stderr {
stderr.push(text.clone());
} else {
stdout.push(text.clone());
}
}
(stdout, stderr)
}
}
#[cfg(test)]
#[path = "raw_window_tests.rs"]
mod tests;
@@ -0,0 +1,46 @@
//! `raw_window` 的单测。
use super::*;
use crate::launch::pump::Stream;
fn line(stream: Stream, text: &str) -> LogLine {
LogLine::new(stream, text)
}
#[test]
fn keeps_the_tail_and_counts_every_error_line() {
let mut window = RawWindow::default();
window.push(&line(
Stream::Stderr,
"java.lang.IllegalStateException: boom",
));
window.push(&line(Stream::Stdout, "[main/INFO]: Setting user"));
assert_eq!(window.error_lines(), 1);
let (stdout, stderr) = window.facts();
assert_eq!(stdout, vec!["[main/INFO]: Setting user".to_string()]);
assert_eq!(
stderr,
vec!["java.lang.IllegalStateException: boom".to_string()]
);
}
/// 尾窗有界:`CAPACITY` 之前的行被丢掉,但**错误行数照旧全量**。
#[test]
fn the_window_is_bounded_while_error_count_is_not() {
let mut window = RawWindow::default();
for index in 0..(CAPACITY + 10) {
window.push(&line(Stream::Stdout, &format!("line {index}")));
}
let (stdout, _) = window.facts();
assert_eq!(stdout.len(), CAPACITY);
assert_eq!(stdout.first().map(String::as_str), Some("line 10"));
assert_eq!(window.error_lines(), 0);
for _ in 0..3 {
window.push(&line(
Stream::Stderr,
"java.lang.OutOfMemoryError: Java heap space",
));
}
assert_eq!(window.error_lines(), 3);
}