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

+25 -7
View File
@@ -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();
}