diff --git a/crates/core/src/launch/placeholders.rs b/crates/core/src/launch/placeholders.rs index 2f2754b..481ef24 100644 --- a/crates/core/src/launch/placeholders.rs +++ b/crates/core/src/launch/placeholders.rs @@ -177,14 +177,22 @@ impl PlaceholderMap { outcome } - /// 登记本次遇到的未登记键,只把**新**见到的那些写成日志行。 + /// 登记本次遇到的未登记键,只把**新**见到的那些写进日志。 fn report(&self, unknown: &[String]) { + self.report_with(unknown, emit); + } + + /// [`Self::report`] 的可注入版本:`sink` 拿到的是「本次**新**见到的键」(去重、保序)。 + /// + /// 留这道缝只为一件事:让「哪些键真的交给了日志」有自动化证据(用例用假 sink 断言), + /// 而不是只能靠读代码——进程级的 `launcher_log` 在 lib 测试里动不得(见 [`emit`] 的注释)。 + fn report_with(&self, unknown: &[String], mut sink: impl FnMut(&str)) { let fresh = { let mut seen = self.seen(); collect_fresh(&mut seen, unknown) }; - for key in fresh { - emit(&key); + for key in &fresh { + sink(key); } } @@ -266,8 +274,12 @@ fn collect_fresh(seen: &mut Vec, keys: &[String]) -> Vec { } /// 未登记键的 `launcher.log` 行(scope 与时间戳由 `launcher_log` 加)。 +/// +/// 键名先做换行消毒:`launcher.log` 是**行导向**的,第三方清单完全可以在 JSON 里塞 +/// `${evil\n2026-… [launch] 伪造行}`,把一个键变成两行假日志。这里换成一个空格。 fn unknown_log_line(key: &str) -> String { - match near_miss(key) { + let key = key.replace(['\r', '\n'], " "); + match near_miss(&key) { Some(known) => format!( "占位符 ${{{key}}} 未登记:已按原样保留;疑似 ${{{known}}} 的笔误\ (替换是精确匹配,大小写/空白都算不同的键)" @@ -297,9 +309,10 @@ fn near_miss(key: &str) -> Option<&'static str> { /// 测试构建里是空实现:`launcher_log` 是**进程级单例**(目录 + 200 行缓冲),lib 的几百条 /// 用例在同一个进程里并行跑,本模块记的几行会落进 `launcher_log` 自己 /// `buffer_is_bounded_before_the_dir_is_known` 的窗口里(实测:`缓冲没有上限:205 行`)。 -/// 让每个用例都去抢那个单例,等于把 flake 转嫁给别人。行文本与「哪些键该记」分别由 -/// [`unknown_log_line`] / [`collect_fresh`] 的用例覆盖;落盘这一环由 -/// `M244` 的一次性探针(`--test-threads=1`,跑完即删)手工验过。 +/// 让每个用例都去抢那个单例,等于把 flake 转嫁给别人。三段各有自动化证据:行文本 +/// [`unknown_log_line`]、「哪些键算新」[`collect_fresh`]、「新键怎么交给 sink」 +/// [`PlaceholderMap::report_with`](用例用假 sink 断言);只剩最后一句 +/// `launcher_log::record` 由 `M244` 的一次性探针(`--test-threads=1`,跑完即删)手工验过。 #[cfg(not(test))] fn emit(key: &str) { crate::launch::launcher_log::record("launch", &unknown_log_line(key)); diff --git a/crates/core/src/launch/placeholders/tests.rs b/crates/core/src/launch/placeholders/tests.rs index 34fb269..7d34bd7 100644 --- a/crates/core/src/launch/placeholders/tests.rs +++ b/crates/core/src/launch/placeholders/tests.rs @@ -8,7 +8,8 @@ //! flake——实测过一次:本模块的几行把 `launcher_log` 的 `缓冲没有上限:205 行` 顶红了。 //! 所以 [`emit`](super::emit) 在测试构建里是空实现,落盘那一环用一次性探针 //! (`--test-threads=1`,跑完即删)手工验过。这里断言的是它的上游——行文本 -//! ([`unknown_log_line`])与「哪些键该记」([`collect_fresh`])——两段合起来就是「记什么」。 +//! ([`unknown_log_line`])、「哪些键算新」([`collect_fresh`])、「新键怎么交给 sink」 +//! ([`PlaceholderMap::report_with`],用假 sink)——三段合起来就是「记什么、记几次」。 use super::{collect_fresh, near_miss, unknown_log_line, PlaceholderMap}; use crate::launch::command_builder::CommandBuilder; @@ -135,3 +136,50 @@ fn rendering_semantics_are_unchanged() { "空键也是未登记键(原样保留)" ); } + +/// ③ 的「真的交给了日志」这一层:`report_with` 只把**本次新**见到的键递给你的 sink。 +/// +/// 变异证明:把 `report_with` 里的 `for key in &fresh` 换成 `for key in unknown`(每次渲染都 +/// 重复写日志)⇒ 本条红(`left: ["a", "a", …]`)——「一个键只报一次、不刷屏」被守住的地方 +/// 就在这里,而不是在 `collect_fresh` 的用例里。 +#[test] +fn report_emits_only_fresh_keys() { + let map = PlaceholderMap::new(); + let mut delivered: Vec = Vec::new(); + map.report_with(&strings(&["a", "a"]), |key| delivered.push(key.to_string())); + assert_eq!(delivered, strings(&["a"]), "一次渲染内的重复只交一次"); + map.report_with(&strings(&["a", "b"]), |key| delivered.push(key.to_string())); + assert_eq!( + delivered, + strings(&["a", "b"]), + "跨渲染只有新键才交,且保序" + ); + assert_eq!( + map.unknown_keys(), + strings(&["a", "b"]), + "表级清单与 sink 同一份" + ); + + // 顺序口径与 `collect_fresh` 一致(同一份规则的两处实现不能漂)。 + let mut fresh_seen = Vec::new(); + assert_eq!( + collect_fresh(&mut fresh_seen, &strings(&["a", "a"])), + strings(&["a"]) + ); + assert_eq!( + collect_fresh(&mut fresh_seen, &strings(&["a", "b"])), + strings(&["b"]) + ); +} + +/// 日志是**行导向**的:恶意/畸形清单可以在 JSON 里塞换行,一个键不能变成两行假日志。 +#[test] +fn log_lines_never_break_into_two_lines() { + let line = unknown_log_line("evil\n2026-01-01T00:00:00Z [launch] 伪造行\r"); + assert_eq!(line.lines().count(), 1, "{line}"); + assert!(!line.contains('\n') && !line.contains('\r'), "{line}"); + assert!( + line.contains("evil 2026-01-01T00:00:00Z [launch] 伪造行"), + "{line}" + ); +}