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

+90
View File
@@ -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}"
);
}