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:
1 parent
74be510114
commit
edd6402fd5
10 files changed
+633
-65
No files matched your search
@@ -7,11 +7,13 @@
|
||||
//!
|
||||
//! 规划中的公共面(M15):信号采集、规则匹配、诊断报告、规则热加载。
|
||||
|
||||
// 只有这五个顶层模块;子模块(collectors 下的 signals/sources/environment、engine 下的
|
||||
// finding/synthetic、hs_err 下的 hs_err_report、rules 下的 fix_action/fix_params)由各自的
|
||||
// 父文件用 #[path] 声明。这里**不要**重复声明,否则同一文件会被编译成两份不同类型。
|
||||
// 只有这六个顶层模块;子模块(collectors 下的 signals/sources/environment、engine 下的
|
||||
// finding/synthetic、hs_err 下的 hs_err_report、rules 下的 fix_action/fix_params、session 下的
|
||||
// session_tests)由各自的父文件用 #[path] 声明。这里**不要**重复声明,否则同一文件会被编译成
|
||||
// 两份不同类型。
|
||||
pub mod collectors;
|
||||
pub mod engine;
|
||||
pub mod hs_err;
|
||||
pub mod loader;
|
||||
pub mod rules;
|
||||
pub mod session;
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 会话收尾的规则结论:**在原始行还热着的时候**算一次(M241)。
|
||||
//!
|
||||
//! ## 为什么不能等读回 JSONL 再算
|
||||
//!
|
||||
//! 会话事件流是「写前先脱敏」的([`crate::launch::redact`],`落盘即脱敏`),而脱敏的两条
|
||||
//! 形状规则是**有损**的:`≥64` 无点号不透明串、带点 token。于是「读回 JSONL → 喂规则引擎」
|
||||
//! 这条路看到的不是游戏说的话,而是**被抹过的**游戏说的话。M239 实测:`fabric.loader_0_12`
|
||||
//! (critical / priority 80)的唯一证据行
|
||||
//! `java.lang.NoClassDefFoundError: org/spongepowered/…/FabricMixinTransformerProxy`
|
||||
//! 里的类路径 66 字符、无点号,正好撞上 `≥64` 那条规则 → 变成 `<redacted:opaque>` →
|
||||
//! 规则在会话路上**永不命中**。
|
||||
//!
|
||||
//! ## 分工(`DevDocs/SubAgentsTalk/launch-接口约定.md` §3)
|
||||
//!
|
||||
//! 「launch 报事实、M15 下结论」没有变:launch 把**原始事实**(退出码、存活时长、原始
|
||||
//! stdout/stderr 尾窗)交过来,结论在 `diagnose` 里算,产出的就是 `diagnosis` 事件的
|
||||
//! payload([`DiagnosisReport::to_record`])。落盘仍然只留**已脱敏**的结论:写会话的
|
||||
//! `SessionWriter::diagnosis` 会再走一遍 `Redactor::scrub_json`。
|
||||
//!
|
||||
//! 换句话说:**原始文本只活在内存里**,盘上永远只有脱敏文本——不是「再存一份原文」。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::diagnose::collectors::{ExitSignal, SignalOrigin};
|
||||
use crate::diagnose::engine::{Diagnoser, DiagnosisInput};
|
||||
use crate::diagnose::loader::RuleLoader;
|
||||
|
||||
/// 会话收尾时交给规则引擎的**原始**事实(未脱敏,只在本进程内存里)。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RawFacts<'a> {
|
||||
/// 退出码(`None` = 没有退出判定)。
|
||||
pub exit_code: Option<i32>,
|
||||
/// 进程存活毫秒数。
|
||||
pub duration_ms: u64,
|
||||
/// 全量错误行数(不受尾窗影响)。
|
||||
pub error_lines: u32,
|
||||
/// 原始 stdout 尾窗(按时间顺序)。
|
||||
pub stdout: &'a [String],
|
||||
/// 原始 stderr 尾窗(按时间顺序)。
|
||||
pub stderr: &'a [String],
|
||||
}
|
||||
|
||||
impl RawFacts<'_> {
|
||||
/// 有没有任何可匹配的文本。
|
||||
fn has_text(&self) -> bool {
|
||||
!self.stdout.is_empty() || !self.stderr.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 跑一遍规则引擎,返回 `diagnosis` 事件的 payload;没有结论时返回 `None`。
|
||||
///
|
||||
/// 返回 `None` 的两种情况都**不写事件**:没有任何文本可匹配(无从判断),或规则引擎没认出
|
||||
/// 已知问题(此时读回 JSONL 的那条兜底路径看到的是同一份证据,重跑也是空手)。
|
||||
#[must_use]
|
||||
pub fn conclude(facts: &RawFacts<'_>) -> Option<Value> {
|
||||
if !facts.has_text() {
|
||||
return None;
|
||||
}
|
||||
let stderr_text = facts.stderr.join("\n");
|
||||
let exit = ExitSignal::classify(
|
||||
facts.exit_code,
|
||||
Some(facts.duration_ms),
|
||||
facts.error_lines,
|
||||
&stderr_text,
|
||||
);
|
||||
let mut input = DiagnosisInput::new().with_exit(exit);
|
||||
if !facts.stdout.is_empty() {
|
||||
input.push_text(SignalOrigin::Stdout, facts.stdout.join("\n"));
|
||||
}
|
||||
if !facts.stderr.is_empty() {
|
||||
input.push_text(SignalOrigin::Stderr, facts.stderr.join("\n"));
|
||||
}
|
||||
let report = Diagnoser::new(rules()).analyze(&input);
|
||||
report.primary()?;
|
||||
serde_json::to_value(report.to_record()).ok()
|
||||
}
|
||||
|
||||
/// 内嵌规则集编译一次的那份规则(每次重新取;`RuleLoader` 自带缓存语义)。
|
||||
fn rules() -> Vec<crate::diagnose::rules::CompiledRule> {
|
||||
RuleLoader::embedded().rule_set().rules().to_vec()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "session_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,90 @@
|
||||
//! `diagnose::session` 的单测(拆文件守 400 行上限)。
|
||||
|
||||
use super::*;
|
||||
use crate::launch::redact::Redactor;
|
||||
|
||||
/// `fabric.loader_0_12`(critical / priority 80)在 `rules.d/fabric.yaml` 里的唯一证据行。
|
||||
const EVIDENCE: &str = "java.lang.NoClassDefFoundError: \
|
||||
org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy";
|
||||
|
||||
fn facts<'a>(stdout: &'a [String], stderr: &'a [String]) -> RawFacts<'a> {
|
||||
RawFacts {
|
||||
exit_code: Some(1),
|
||||
duration_ms: 4_200,
|
||||
error_lines: 2,
|
||||
stdout,
|
||||
stderr,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_id(record: &Value) -> Option<&str> {
|
||||
record
|
||||
.get("candidates")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// **判据 ①**:原始证据行能命中 `fabric.loader_0_12`;同一行被脱敏后命不中。
|
||||
///
|
||||
/// 后半句不是「补充说明」,它是这个模块存在的理由——会话 JSONL 里只有脱敏文本。
|
||||
#[test]
|
||||
fn raw_evidence_hits_the_fabric_rule_while_scrubbed_evidence_misses_it() {
|
||||
let raw = [EVIDENCE.to_string()];
|
||||
let empty: [String; 0] = [];
|
||||
|
||||
let record = conclude(&facts(&empty, &raw)).expect("原始证据应当有结论");
|
||||
assert_eq!(record_id(&record), Some("fabric.loader_0_12"), "{record}");
|
||||
assert_eq!(record["source"], serde_json::json!("rules"));
|
||||
assert_eq!(record["needs_more_info"], serde_json::json!(false));
|
||||
|
||||
let scrubbed = [Redactor::new().scrub(EVIDENCE).into_owned()];
|
||||
assert!(
|
||||
scrubbed[0].contains("<redacted:opaque>"),
|
||||
"夹具前提:这条证据行确实会被抹:{}",
|
||||
scrubbed[0]
|
||||
);
|
||||
let after = conclude(&facts(&empty, &scrubbed));
|
||||
assert_ne!(
|
||||
after.as_ref().and_then(record_id),
|
||||
Some("fabric.loader_0_12"),
|
||||
"脱敏后的文本不该再命中——这就是 M239 报的那个 bug"
|
||||
);
|
||||
}
|
||||
|
||||
/// 没有文本就没有结论(不写空事件)。
|
||||
#[test]
|
||||
fn no_text_means_no_conclusion() {
|
||||
let empty: [String; 0] = [];
|
||||
assert!(conclude(&facts(&empty, &empty)).is_none());
|
||||
}
|
||||
|
||||
/// 认不出来的日志也不写事件(让读回 JSONL 的兜底路径去跑,避免两条路给出不同结论)。
|
||||
#[test]
|
||||
fn unrecognized_output_means_no_conclusion() {
|
||||
let stdout = ["[12:00:00] [main/INFO]: Setting user".to_string()];
|
||||
let empty: [String; 0] = [];
|
||||
assert!(conclude(&facts(&stdout, &empty)).is_none());
|
||||
}
|
||||
|
||||
/// 结论里带的是**已渲染**的文案与物化动作,展示侧不必再自己拼。
|
||||
#[test]
|
||||
fn the_record_carries_rendered_text_and_actions() {
|
||||
let raw = ["Fabric loader failed".to_string(), EVIDENCE.to_string()];
|
||||
let empty: [String; 0] = [];
|
||||
let record = conclude(&facts(&empty, &raw)).expect("应有结论");
|
||||
let candidate = &record["candidates"][0];
|
||||
assert!(!candidate["message"].as_str().unwrap_or_default().is_empty());
|
||||
assert_eq!(
|
||||
candidate["fix_actions"][0]["kind"],
|
||||
serde_json::json!("reinstall")
|
||||
);
|
||||
assert!(
|
||||
candidate["evidence"][0]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("NoClassDefFoundError"),
|
||||
"{candidate}"
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -28,6 +28,8 @@ use crate::dto::{
|
||||
|
||||
use super::env::HostEnv;
|
||||
|
||||
#[path = "logs/diagnosis.rs"]
|
||||
mod diagnosis;
|
||||
#[path = "logs/location.rs"]
|
||||
mod location;
|
||||
|
||||
@@ -141,30 +143,43 @@ pub fn line_of(event: &Value) -> Option<LogLine> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 整个事件流 → 日志行(解析失败的行跳过,容忍被 kill 时截断的尾行)。
|
||||
pub fn read_lines(path: &Path) -> Vec<LogLine> {
|
||||
/// 整个事件流的原始 JSON 行(解析失败的行跳过,容忍被 kill 时截断的尾行)。
|
||||
///
|
||||
/// 一次读完所有事件:日志行与诊断结论都从这同一份读里取,不重复读盘。
|
||||
pub fn read_events(path: &Path) -> Vec<Value> {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
text.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter_map(|event| line_of(&event))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 整个事件流 → 日志行(**展示面**:`payload.line`,即写盘前已脱敏的文本)。
|
||||
pub fn read_lines(path: &Path) -> Vec<LogLine> {
|
||||
read_events(path).iter().filter_map(line_of).collect()
|
||||
}
|
||||
|
||||
/// 打开一个会话:日志行 + 规则引擎结论。
|
||||
///
|
||||
/// 结论优先取会话里**落盘的那份**(写会话时用原始行算过,见 [`diagnosis`] 模块文档);
|
||||
/// 老会话没有它,才回落到「在脱敏文本上重跑规则」。
|
||||
pub fn open(env: &HostEnv, key: &str) -> (Vec<LogLine>, Option<DiagnosisReport>) {
|
||||
let Some(entry) = find(env, key) else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
let path = event_path(env, &entry);
|
||||
let lines = read_lines(&path);
|
||||
let diagnosis = diagnose_lines(&entry, &lines);
|
||||
let events = read_events(&event_path(env, &entry));
|
||||
let lines: Vec<LogLine> = events.iter().filter_map(line_of).collect();
|
||||
let diagnosis = diagnosis::stored(&entry, &events).or_else(|| diagnose_lines(&entry, &lines));
|
||||
(lines, diagnosis)
|
||||
}
|
||||
|
||||
/// 规则引擎跑一遍(**规则先跑,模型补位**:这里只出规则结论)。
|
||||
/// 在**脱敏文本**上重跑规则引擎(老会话的兜底路径)。
|
||||
///
|
||||
/// ⚠️ 这条路的证据是「写盘前脱敏过的」行:`≥64` 无点号长串(Sponge 类路径、长 hash)会被
|
||||
/// 抹成 `<redacted:opaque>`,依赖那些字面量的规则(`fabric.loader_0_12`)在这里**不可能命中**。
|
||||
/// 新会话通常不必走这条路——写会话时用原始行算过结论,见 [`diagnosis::stored`]。
|
||||
pub fn diagnose_lines(entry: &SessionIndexEntry, lines: &[LogLine]) -> Option<DiagnosisReport> {
|
||||
let stderr_text: String = lines
|
||||
.iter()
|
||||
@@ -355,6 +370,9 @@ pub fn session_finished(env: &HostEnv, session: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "logs/m241_tests.rs"]
|
||||
mod m241_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "logs_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,82 @@
|
||||
//! 会话里落盘的诊断结论(M241):读回时优先用它,不要拿脱敏文本重跑规则。
|
||||
//!
|
||||
//! ## 为什么不能重跑
|
||||
//!
|
||||
//! 会话 JSONL 是**写前脱敏**的,脱敏的两条形状规则有损:`≥64` 无点号不透明串会把证据行
|
||||
//! 一起抹掉(`fabric.loader_0_12` 的 `org/spongepowered/…/FabricMixinTransformerProxy`
|
||||
//! 66 字符 → `<redacted:opaque>`)。在脱敏文本上重跑规则必然空手——M239 实测那条 critical
|
||||
//! 规则在会话路上「永不命中」。
|
||||
//!
|
||||
//! 修法是**换证据**而不是换判据:写会话时原始行还在内存里,那时已经算过一份结论并落进
|
||||
//! `diagnosis` 事件(`launch::session::SessionWriter::finish` → `diagnose::session::conclude`),
|
||||
//! 那份结论在盘上仍然是脱敏过的(`Redactor::scrub_json`)。展示侧只读它。
|
||||
//!
|
||||
//! 老会话(本次改动之前写的 JSONL)没有 `diagnosis` 事件 → 调用方回落到
|
||||
//! [`super::diagnose_lines`](在脱敏文本上重跑),行为与今天一致,不会凭空少一张卡片。
|
||||
|
||||
use serde_json::Value;
|
||||
use unlucky_core::launch::session_index::SessionIndexEntry;
|
||||
|
||||
use crate::dto::{Diagnosis, DiagnosisReport};
|
||||
|
||||
use super::confidence_percent;
|
||||
|
||||
/// 事件流里最后一条 `diagnosis` 事件 → 日志页的诊断卡片 DTO。
|
||||
///
|
||||
/// 正常只会有一条(挂钩写的,或收尾补的,二选一——见 `SessionWriter::conclude_from_raw` 的门槛)。
|
||||
/// 真出现多条时取**最后一条**:收尾补的那份证据最新鲜。
|
||||
pub(super) fn stored(entry: &SessionIndexEntry, events: &[Value]) -> Option<DiagnosisReport> {
|
||||
let payload = events
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|event| event.get("kind").and_then(Value::as_str) == Some("diagnosis"))?
|
||||
.get("payload")?;
|
||||
let candidate = payload
|
||||
.get("candidates")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.or_else(|| {
|
||||
payload
|
||||
.get("secondary")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
})?;
|
||||
let rule = candidate.get("id").and_then(Value::as_str)?;
|
||||
let title = candidate.get("message").and_then(Value::as_str)?;
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let confidence = candidate
|
||||
.get("confidence")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or_default();
|
||||
let hint = candidate
|
||||
.get("detail")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
payload
|
||||
.get("exit")
|
||||
.and_then(|exit| exit.get("exit_type"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| "未知退出".to_string());
|
||||
let fix = candidate
|
||||
.get("fix_actions")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|action| action.get("tool"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
Some(DiagnosisReport {
|
||||
session: entry.session_id.clone(),
|
||||
diagnosis: Diagnosis {
|
||||
rule: rule.to_string(),
|
||||
title: title.to_string(),
|
||||
confidence: confidence_percent(confidence as f32),
|
||||
hint,
|
||||
fix,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! M241:诊断吃**原始**文本、展示/导出只吃脱敏文本(拆文件守 400 行上限)。
|
||||
|
||||
use super::*;
|
||||
use unlucky_core::launch::exit_analysis::{self, ExitSignals};
|
||||
use unlucky_core::launch::pump::{LogLine as CoreLine, Stream};
|
||||
use unlucky_core::launch::redact::Redactor;
|
||||
use unlucky_core::launch::session::{EndReason, SessionMeta, SessionWriter};
|
||||
|
||||
/// 一个临时数据根(`<root>/.ULL` + `<root>/.minecraft`)。
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("ull-m241-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
root
|
||||
}
|
||||
|
||||
/// `fabric.loader_0_12`(critical / priority 80)在 `rules.d/fabric.yaml:114` 的唯一证据行。
|
||||
///
|
||||
/// 类路径 66 字符、无点号 → 撞上 `redact.rs` 的「≥64 不透明串」规则 → 脱敏后被抹成
|
||||
/// `<redacted:opaque>`。这正是 M239 报的那个「会话路上永不命中」。
|
||||
const EVIDENCE: &str = "java.lang.NoClassDefFoundError: \
|
||||
org/spongepowered/asm/mixin/transformer/FabricMixinTransformerProxy";
|
||||
/// 真凭据(写盘前必须被抹掉的形状)。
|
||||
const TOKEN: &str = "SECRET-TOKEN-123456";
|
||||
|
||||
fn meta() -> SessionMeta {
|
||||
SessionMeta::new("inst", "1.20.1", "1.20.1")
|
||||
}
|
||||
|
||||
/// 判据(M241):① 规则引擎对含长类名的行**能命中**;② 用户可见/导出的文本**仍是被脱敏的**。
|
||||
///
|
||||
/// 端到端走真链路:`SessionWriter` 写盘(写前脱敏)→ 真 JSONL → [`open`] 读回。
|
||||
///
|
||||
/// 变异证明:
|
||||
/// - 把 `SessionWriter::finish` 里补结论的输入换成脱敏后的行 ⇒ ① 红(`fabric.loader_0_12`
|
||||
/// 命不中,`diagnosis_top` 不是它);
|
||||
/// - 把 `SessionWriter::line` 里的 `scrub` 去掉(导出面改成原始文本)⇒ ② 红(类路径与凭据
|
||||
/// 原样出现在展示行与盘上)。
|
||||
#[test]
|
||||
fn raw_evidence_hits_the_rule_while_the_export_surface_stays_redacted() {
|
||||
let root = temp_root("diagraw");
|
||||
let env = HostEnv::at_root(&root);
|
||||
let redactor = Redactor::new().with_secret(TOKEN);
|
||||
let mut writer =
|
||||
SessionWriter::create_with_id(&env.sessions_root(), "diagraw-1", meta(), redactor)
|
||||
.expect("建会话");
|
||||
writer.session_start().expect("session_start");
|
||||
let lines = vec![
|
||||
CoreLine::new(Stream::Stderr, "Failed to start Fabric loader"),
|
||||
CoreLine::new(Stream::Stderr, EVIDENCE),
|
||||
CoreLine::new(Stream::Stdout, format!("auth: token={TOKEN}")),
|
||||
];
|
||||
for line in &lines {
|
||||
writer.line(line).expect("写行");
|
||||
}
|
||||
let analysis = exit_analysis::analyze(&ExitSignals::new(1, 40_000, &lines));
|
||||
writer.exit(&analysis).expect("exit");
|
||||
let entry = writer.finish(EndReason::Exit).expect("finish");
|
||||
|
||||
// ① 规则对含长类名的行能命中(结论在原始行上算过,落进索引)。
|
||||
assert_eq!(
|
||||
entry.diagnosis_top.as_deref(),
|
||||
Some("fabric.loader_0_12"),
|
||||
"会话路上必须命中 fabric.loader_0_12"
|
||||
);
|
||||
let (opened, diagnosis) = open(&env, "diagraw-1");
|
||||
assert_eq!(
|
||||
diagnosis.map(|report| report.diagnosis.rule),
|
||||
Some("fabric.loader_0_12".to_string())
|
||||
);
|
||||
|
||||
// ② 展示面(日志行 DTO)仍是被脱敏的。
|
||||
assert!(
|
||||
opened
|
||||
.iter()
|
||||
.any(|line| line.text.contains("<redacted:opaque>")),
|
||||
"长类名必须被抹成占位符:{opened:?}"
|
||||
);
|
||||
assert!(
|
||||
!opened
|
||||
.iter()
|
||||
.any(|line| line.text.contains("FabricMixinTransformerProxy")),
|
||||
"展示行不许出现原始类路径:{opened:?}"
|
||||
);
|
||||
assert!(
|
||||
!opened.iter().any(|line| line.text.contains(TOKEN)),
|
||||
"展示行不许出现凭据:{opened:?}"
|
||||
);
|
||||
|
||||
// ②' 盘上那份(= 导出包收进去的同一份文件)也没有原文——修 bug 不等于把密钥写进盘。
|
||||
let disk = std::fs::read_to_string(event_path(&env, &entry)).expect("读会话文件");
|
||||
assert!(!disk.contains("FabricMixinTransformerProxy"), "{disk}");
|
||||
assert!(!disk.contains(TOKEN), "{disk}");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
|
||||
/// 老会话(本次改动之前写的 JSONL,没有 `diagnosis` 事件)走兜底:在脱敏文本上重跑规则。
|
||||
///
|
||||
/// 判据:短字面量的规则(`memory.out_of_memory`)在脱敏文本上照样命中 ⇒ 兜底没坏。
|
||||
#[test]
|
||||
fn sessions_without_a_stored_conclusion_still_get_a_card() {
|
||||
let root = temp_root("diagraw-legacy");
|
||||
let env = HostEnv::at_root(&root);
|
||||
let entry = SessionIndexEntry {
|
||||
session_id: "legacy".to_string(),
|
||||
instance_id: "inst".to_string(),
|
||||
started_at: "2026-11-01T10:00:00.000Z".to_string(),
|
||||
ended_at: "2026-11-01T10:01:00.000Z".to_string(),
|
||||
exit_code: Some(1),
|
||||
exit_type: Some("application_error".to_string()),
|
||||
log_path: "game/legacy.jsonl".to_string(),
|
||||
..SessionIndexEntry::default()
|
||||
};
|
||||
session_index::append(&session_index::index_path(&env.sessions_root()), &entry)
|
||||
.expect("写索引");
|
||||
let file = env.sessions_root().join("game/legacy.jsonl");
|
||||
std::fs::create_dir_all(file.parent().expect("父目录")).expect("建目录");
|
||||
std::fs::write(
|
||||
&file,
|
||||
concat!(
|
||||
"{\"kind\":\"stderr\",\"payload\":{\"line\":\"java.lang.OutOfMemoryError: Java heap space\",\"level\":\"error\"}}\n",
|
||||
"{\"kind\":\"session_end\",\"payload\":{\"reason\":\"exit\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("写会话");
|
||||
|
||||
let (lines, diagnosis) = open(&env, "legacy");
|
||||
assert!(lines
|
||||
.iter()
|
||||
.any(|line| line.text.contains("OutOfMemoryError")));
|
||||
assert_eq!(
|
||||
diagnosis.map(|report| report.diagnosis.rule),
|
||||
Some("memory.out_of_memory".to_string()),
|
||||
"没有落盘结论时应回落到规则重跑"
|
||||
);
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
Reference in new issue
Block a user