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}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user