会话 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 行上限。
91 lines
3.2 KiB
Rust
91 lines
3.2 KiB
Rust
//! `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}"
|
||
);
|
||
}
|