From 60607ae7b32ce4f16a7cdb3d52d38200e29d5730 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 11:12:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E6=9C=AA=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E7=AC=A6=E4=B8=8D=E5=86=8D=E9=9D=99=E9=BB=98?= =?UTF-8?q?=EF=BC=88M244=20PLACEHOLDERWARN=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlaceholderMap::render` 一直把未登记的 `${key}` 原样写回 argv,运行期零校验零告警: 第三方 version JSON 里一个拼错的占位符会原样进 `cmd_built.argv` / `--dry-run`, 用户只看到一个 `${xxx}` 和零解释(M239 REDACTAUDIT §5.2 的机制风险)。 - `render_tracked` 返回 `RenderOutcome { text, unknown_keys }`,未登记键去重保序; `unknown_keys()` 交表级全量清单,供后续接 `AssemblyWarning` / `--dry-run`。 - `render` 在此之上把每个**新**见到的键写一行 `launcher.log`(进程级去重,一个键一次启动 只报一次);未登记键仍原样保留,argv 逐字节不变(第三方清单兼容性优先)。 - 大小写/首尾空白变体按**不同的键**处理(替换本来就是精确匹配),日志行对近似已登记键的 附「疑似 ${known} 的笔误」提示。 - 测试构建里 `emit` 是空实现:`launcher_log` 是进程级单例,lib 用例并行跑时会把它自己的 `buffer_is_bounded_before_the_dir_is_known` 顶红(实测 205 行);行文本与「哪些键该记」 分别由 `unknown_log_line` / `collect_fresh` 的用例覆盖,落盘用一次性探针手工验过。 变异自证:把 `note_unknown` 改成空实现 ⇒ 5 条新用例红(exit 101),还原后绿。 门禁:fmt / check -p unlucky-core --all-targets / test -p unlucky-core -p unlucky-cli / clippy --workspace --all-targets -- -D warnings 全绿。 --- crates/core/src/launch/placeholders.rs | 179 +++++++++++++++++-- crates/core/src/launch/placeholders/tests.rs | 137 ++++++++++++++ 2 files changed, 303 insertions(+), 13 deletions(-) create mode 100644 crates/core/src/launch/placeholders/tests.rs diff --git a/crates/core/src/launch/placeholders.rs b/crates/core/src/launch/placeholders.rs index b99f42b..2f2754b 100644 --- a/crates/core/src/launch/placeholders.rs +++ b/crates/core/src/launch/placeholders.rs @@ -2,10 +2,27 @@ //! //! 占位符清单平移自 `analyse/02` §4.8(`DefaultLauncher.getConfigurations()`): //! 版本清单里的 `arguments.*` 用 `${...}` 引用运行时值,启动器负责在组装时替换。 -//! 替换语义与 HMCL 一致:**单遍、不递归**;未定义的占位符原样保留(便于发现清单漏项)。 +//! 替换语义与 HMCL 一致:**单遍、不递归**。 +//! +//! ## 未登记占位符:原样保留,但不再静默(M244) +//! +//! 未登记键(第三方清单里自造的 `${xxx}`)**仍然原样写进 argv**——第三方清单的兼容性优先, +//! 悄悄改写别人给的命令行比留着 `${xxx}` 危险得多。但「静默」是另一回事:argv、 +//! `--dry-run` 与会话 JSONL 里只有一个 `${xxx}`,没有任何解释。所以渲染时会把这些键收集起来: +//! +//! - [`PlaceholderMap::render`] 每个键每次启动只往 `launcher.log` 记一行 +//! ([`crate::launch::launcher_log`] 是 `crates/core` 里唯一 GUI 与 CLI 都写、且事故之后 +//! 还能翻的落点,见该模块文档); +//! - [`PlaceholderMap::render_tracked`] / [`PlaceholderMap::unknown_keys`] 把同一份清单 +//! **结构化**交出来,给后续接 `AssemblyWarning` / `--dry-run` 留好接口。 +//! +//! 键的匹配是**精确字节相等**:`${Version_Name}`、`${ version_name }` 与 `${version_name}` +//! 是三个不同的键(替换本身就不认它们,合并只会掩盖「这个串没被替换」这个事实)。日志行里 +//! 会提示大小写/空白近似的那一个,方便定位笔误。 use std::collections::BTreeMap; use std::path::Path; +use std::sync::{Mutex, MutexGuard}; use crate::launch::platform::PlatformCtx; use crate::launch::request::LaunchRequest; @@ -40,10 +57,42 @@ pub const PLACEHOLDER_KEYS: &[&str] = &[ "language", ]; +/// 一次渲染的结果。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RenderOutcome { + /// 替换后的参数串(未登记键原样保留)。 + pub text: String, + /// 本次渲染遇到的未登记键(不含 `${}`);按首次出现顺序去重。 + pub unknown_keys: Vec, +} + +impl RenderOutcome { + /// 记一个未登记键(已记过的不重复记)。 + fn note_unknown(&mut self, key: &str) { + if self.unknown_keys.iter().any(|known| known == key) { + return; + } + self.unknown_keys.push(key.to_string()); + } +} + /// 占位符表。 -#[derive(Debug, Clone, Default)] +/// +/// 除了键值对,还记着「渲染时见过哪些未登记键」(去重保序,[`Self::unknown_keys`] 交出)。 +/// 这份记录是**附加物**:它不参与替换,也不改变替换结果。 +#[derive(Debug, Default)] pub struct PlaceholderMap { entries: BTreeMap, + reported: Mutex>, +} + +impl Clone for PlaceholderMap { + fn clone(&self) -> Self { + Self { + entries: self.entries.clone(), + reported: Mutex::new(self.reported_keys()), + } + } } impl PlaceholderMap { @@ -72,37 +121,81 @@ impl PlaceholderMap { self.entries.is_empty() } - /// 单遍替换 `${key}`;未定义键原样保留。 + /// 到目前为止渲染时遇到的未登记键(去重、保序)。 + /// + /// 这是给「结构化交出口」用的:接 `AssemblyWarning` / `--dry-run` 时读这份清单即可, + /// 不必再解析 `launcher.log`。 + pub fn unknown_keys(&self) -> Vec { + self.reported_keys() + } + + /// 单遍替换 `${key}`;未登记键原样保留,并记一行 `launcher.log`(每个键只记一次)。 + /// + /// 需要同一份结果的**结构化**形式(未登记键清单)时用 [`Self::render_tracked`]。 pub fn render(&self, template: &str) -> String { + let outcome = self.render_tracked(template); + self.report(&outcome.unknown_keys); + outcome.text + } + + /// 单遍替换 `${key}`,并把本次遇到的未登记键一并交出(本方法**不**写日志)。 + pub fn render_tracked(&self, template: &str) -> RenderOutcome { + let mut outcome = RenderOutcome { + text: String::new(), + unknown_keys: Vec::new(), + }; if !template.contains("${") { - return template.to_string(); + outcome.text.push_str(template); + return outcome; } - let mut out = String::with_capacity(template.len() + 16); + outcome.text.reserve(template.len() + 16); let mut rest = template; while let Some(start) = rest.find("${") { - out.push_str(&rest[..start]); + outcome.text.push_str(&rest[..start]); let after = &rest[start + 2..]; match after.find('}') { Some(end) => { let key = &after[..end]; match self.entries.get(key) { - Some(value) => out.push_str(value), + Some(value) => outcome.text.push_str(value), None => { - out.push_str("${"); - out.push_str(key); - out.push('}'); + outcome.text.push_str("${"); + outcome.text.push_str(key); + outcome.text.push('}'); + outcome.note_unknown(key); } } rest = &after[end + 1..]; } None => { - out.push_str(&rest[start..]); + outcome.text.push_str(&rest[start..]); rest = ""; } } } - out.push_str(rest); - out + outcome.text.push_str(rest); + outcome + } + + /// 登记本次遇到的未登记键,只把**新**见到的那些写成日志行。 + fn report(&self, unknown: &[String]) { + let fresh = { + let mut seen = self.seen(); + collect_fresh(&mut seen, unknown) + }; + for key in fresh { + emit(&key); + } + } + + fn seen(&self) -> MutexGuard<'_, Vec> { + self.reported + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + } + + fn reported_keys(&self) -> Vec { + self.seen().clone() } /// 由启动请求构造完整占位符表。 @@ -159,6 +252,63 @@ impl PlaceholderMap { } } +/// 把 `keys` 登记进 `seen`,返回本次**新**见到的那些(保序;两边都按字节相等去重)。 +fn collect_fresh(seen: &mut Vec, keys: &[String]) -> Vec { + let mut fresh = Vec::new(); + for key in keys { + if seen.iter().any(|known| known == key) { + continue; + } + seen.push(key.clone()); + fresh.push(key.clone()); + } + fresh +} + +/// 未登记键的 `launcher.log` 行(scope 与时间戳由 `launcher_log` 加)。 +fn unknown_log_line(key: &str) -> String { + match near_miss(key) { + Some(known) => format!( + "占位符 ${{{key}}} 未登记:已按原样保留;疑似 ${{{known}}} 的笔误\ + (替换是精确匹配,大小写/空白都算不同的键)" + ), + None => format!("占位符 ${{{key}}} 未登记:已按原样保留(第三方版本清单常带自有占位符)"), + } +} + +/// 大小写/首尾空白不同、但确实登记了的那个键(只用于日志提示,不参与替换与去重)。 +/// +/// 候选集用 [`PLACEHOLDER_KEYS`] 而不是 `self.entries`:`from_request` 登记的就是这 26 个 +/// (`crates/core/tests/launch/cmdline_snapshot.rs` 的 `placeholder_table_is_complete` 守这条), +/// 而本函数是纯函数,拿不到表。 +fn near_miss(key: &str) -> Option<&'static str> { + let trimmed = key.trim(); + if trimmed.is_empty() { + return None; + } + PLACEHOLDER_KEYS + .iter() + .copied() + .find(|known| known.eq_ignore_ascii_case(trimmed)) +} + +/// 把一行「未登记占位符」写进 `launcher.log`。 +/// +/// 测试构建里是空实现:`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`,跑完即删)手工验过。 +#[cfg(not(test))] +fn emit(key: &str) { + crate::launch::launcher_log::record("launch", &unknown_log_line(key)); +} + +/// 见上面非测试版本(测试里刻意不碰进程级日志单例)。 +#[cfg(test)] +fn emit(_key: &str) {} + /// 按平台分隔符拼接 classpath。 pub fn join_classpath(paths: &[std::path::PathBuf], separator: char) -> String { paths @@ -171,3 +321,6 @@ pub fn join_classpath(paths: &[std::path::PathBuf], separator: char) -> String { fn path_str(path: &Path) -> String { path.display().to_string() } + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/launch/placeholders/tests.rs b/crates/core/src/launch/placeholders/tests.rs new file mode 100644 index 0000000..34fb269 --- /dev/null +++ b/crates/core/src/launch/placeholders/tests.rs @@ -0,0 +1,137 @@ +//! 未登记占位符的可观测性(M244 PLACEHOLDERWARN)。 +//! +//! 判据四条:① 已登记键仍正常替换;② 未登记键原样保留且被收集到;③ 同一 key 只报一次; +//! ④ 大小写/空白变体按「不同的键」处理,但日志行给出笔误提示。 +//! +//! 日志落盘那一环(`launcher_log::record`)刻意不在单元测试里断言:它是**进程全局**单例 +//! (`launcher_log` 自己的用例要排他地改目录),在并行跑的 lib 测试里抢它是给别人制造 +//! flake——实测过一次:本模块的几行把 `launcher_log` 的 `缓冲没有上限:205 行` 顶红了。 +//! 所以 [`emit`](super::emit) 在测试构建里是空实现,落盘那一环用一次性探针 +//! (`--test-threads=1`,跑完即删)手工验过。这里断言的是它的上游——行文本 +//! ([`unknown_log_line`])与「哪些键该记」([`collect_fresh`])——两段合起来就是「记什么」。 + +use super::{collect_fresh, near_miss, unknown_log_line, PlaceholderMap}; +use crate::launch::command_builder::CommandBuilder; + +fn strings(items: &[&str]) -> Vec { + items.iter().map(|item| (*item).to_string()).collect() +} + +fn map_with_version() -> PlaceholderMap { + let mut map = PlaceholderMap::new(); + map.insert("version_name", "1.20.1"); + map.insert("natives_directory", "C:/mc/natives"); + map +} + +/// ① 正例:已登记键照旧替换(嵌入串里也一样)。 +#[test] +fn registered_keys_are_still_substituted() { + let map = map_with_version(); + let outcome = map.render_tracked("--version ${version_name} -Dnatives=${natives_directory}"); + assert_eq!(outcome.text, "--version 1.20.1 -Dnatives=C:/mc/natives"); + assert!(outcome.unknown_keys.is_empty(), "{outcome:?}"); + assert_eq!(map.render("a${version_name}b"), "a1.20.1b"); +} + +/// ② 未登记键原样保留,并且**被收集到**(这条是新增断言:改回空实现就红)。 +#[test] +fn unknown_keys_are_kept_verbatim_and_collected() { + let map = map_with_version(); + let outcome = map.render_tracked("--feature ${third_party_flag} ${version_name}"); + assert_eq!( + outcome.text, "--feature ${third_party_flag} 1.20.1", + "未登记键必须原样保留" + ); + assert_eq!(outcome.unknown_keys, strings(&["third_party_flag"])); + // 走 `render` 的那条路也会把它记进表里。 + assert_eq!(map.render("${third_party_flag}"), "${third_party_flag}"); + assert_eq!(map.unknown_keys(), strings(&["third_party_flag"])); +} + +/// ② 的 argv 侧:组装出来的参数串里 `${}` 一个字节都不变(第三方清单兼容性优先)。 +#[test] +fn command_builder_keeps_unknown_placeholders_in_argv() { + let mut builder = CommandBuilder::with_placeholders(map_with_version()); + builder.add("--version"); + builder.add("${version_name}"); + builder.add("${third_party_flag}"); + assert_eq!( + builder.args(), + &strings(&["--version", "1.20.1", "${third_party_flag}"]) + ); + assert_eq!( + builder.placeholders().unknown_keys(), + strings(&["third_party_flag"]) + ); +} + +/// ③ 同一 key 只报一次:一次渲染内去重、跨渲染也只在「新见到」时才写日志。 +#[test] +fn the_same_key_is_reported_once() { + let map = PlaceholderMap::new(); + let outcome = map.render_tracked("${b}${a}${b}${a}"); + assert_eq!(outcome.text, "${b}${a}${b}${a}"); + assert_eq!(outcome.unknown_keys, strings(&["b", "a"]), "保序 + 去重"); + + // 表级累计:连着渲染两次,清单里还是这两个。 + let map = PlaceholderMap::new(); + assert_eq!(map.render("${b}${a}"), "${b}${a}"); + assert_eq!(map.render("${a}${b}${c}"), "${a}${b}${c}"); + assert_eq!(map.unknown_keys(), strings(&["b", "a", "c"])); + + // 写日志的那一步:`report` 只把新键交给 `emit`(测试构建里它不碰全局日志)。 + let mut seen = Vec::new(); + assert_eq!( + collect_fresh(&mut seen, &strings(&["a", "a"])), + strings(&["a"]) + ); + assert!(collect_fresh(&mut seen, &strings(&["a"])).is_empty()); + assert_eq!( + collect_fresh(&mut seen, &strings(&["b", "a"])), + strings(&["b"]) + ); +} + +/// ④ 大小写/空白变体算**不同的键**(替换是精确匹配,合并会掩盖「这个串没被替换」), +/// 但日志行把近似的那一个点出来,方便定位笔误。 +#[test] +fn case_and_whitespace_variants_are_distinct_keys_with_a_hint() { + let map = map_with_version(); + let outcome = map.render_tracked("${version_name}|${Version_Name}|${version_name }"); + assert_eq!( + outcome.text, "1.20.1|${Version_Name}|${version_name }", + "变体没被替换 ⇒ 必须原样保留(不能悄悄合并成已登记键)" + ); + assert_eq!( + outcome.unknown_keys, + strings(&["Version_Name", "version_name "]), + "变体是各自独立的键" + ); + assert_eq!(near_miss("Version_Name"), Some("version_name")); + assert_eq!(near_miss(" version_name "), Some("version_name")); + assert_eq!(near_miss("third_party_flag"), None); + + let hinted = unknown_log_line("Version_Name"); + assert!(hinted.contains("${Version_Name}"), "{hinted}"); + assert!(hinted.contains("${version_name}"), "{hinted}"); + let plain = unknown_log_line("third_party_flag"); + assert!(plain.contains("${third_party_flag}"), "{plain}"); + assert!(!plain.contains("笔误"), "{plain}"); +} + +/// 替换语义没被顺手改掉的边角:不闭合的 `${` 原样留、单遍不递归。 +#[test] +fn rendering_semantics_are_unchanged() { + let mut map = PlaceholderMap::new(); + map.insert("a", "${b}"); + map.insert("b", "boom"); + assert_eq!(map.render("${a}"), "${b}", "单遍、不递归"); + assert_eq!(map.render("tail ${"), "tail ${"); + assert_eq!(map.render("no placeholders"), "no placeholders"); + assert_eq!( + PlaceholderMap::new().render_tracked("${}").unknown_keys, + strings(&[""]), + "空键也是未登记键(原样保留)" + ); +}