diff --git a/crates/core/tests/child_quiet.rs b/crates/core/tests/child_quiet.rs index 4d860d4..da98240 100644 --- a/crates/core/tests/child_quiet.rs +++ b/crates/core/tests/child_quiet.rs @@ -1,260 +1,13 @@ -//! M213 守卫:`crates/core/src` 里**所有**子进程都必须经 `platform::child::quiet` 起。 +//! M213:`platform::child::quiet` 的**行为**测试(它仍是一个普通 `Command`)。 //! -//! ## 它判什么(按角色,不按次数) +//! 抑制窗口最容易顺手搞坏的是「`.output()` 的管道、退出码、env、错误语义」, +//! 所以这一组用真程序把这四件事各钉一条。 //! -//! 唯一的问题是「这一处直接构造子进程**属于哪种角色**、是不是绕开了构造器」。所以判据是: -//! 每一处直接构造的**文件**必须命中 [`ALLOWED_ROLES`] 里的某一条角色;不在其中就是绕开了 -//! `platform::child::quiet`。新加一处裸构造时,红的信息是「某文件某行不属于任何允许角色」, -//! 而不是「总数对不上 N」——所以它咬得住「随手再 spawn 一个命令」这种增量变异。 -//! -//! 另外每个允许角色还钉了**恰好一处**:角色内部长出第二处裸构造同样会红(例如有人在 -//! `platform/child.rs` 里绕开 `quiet` 再写一个 `Command::new`)。 -//! -//! ## 它抓不住什么(写在前面,别把它当完备保证) -//! -//! - **别名类型**:`type Cmd = std::process::Command;` 之后 `Cmd::new(..)`。本文件只认 -//! `Command as <别名>` 与 `type <别名> = ...Command` 这两种写法导出的别名(认得的会拦), -//! 但 `type A = B; type B = Command` 这种两跳的、或从别的模块 `pub use` 进来的类型别名抓不住。 -//! - **不经过 `::new` 的构造**:`std::process::Command` 目前只有 `new` / `default`, -//! 没有 `From`,所以实际风险低;真出现了本文件看不见。 -//! - **只剥「整行注释」**:行尾注释里写 `Command::new` 会**误报**;行中开始的跨行块注释 -//! (`let x = 1; /* …` 续行)之后那几行也会被当成代码,同样是**误报**。 -//! 两个方向都是「宁可红」,与 M213 复审 P1-1 那个假绿方向相反。 -//! - **空白**:判据是字面子串 `Command::new(` / `<别名>::new(`,不做空白归一化, -//! 所以 `Command :: new(..)` 看不见。`rustfmt` 会把它规范回去,只有不经 `rustfmt` -//! 的代码才有这个口子。 -//! - **别的 crate**:`crates/cli` / `crates/presenter` / `crates/app-iced` 也可能 spawn 进程, -//! 本守卫的射程只有 `crates/core/src`(M213 的 scope)。 -//! 复审已确认至少一处漏网:`crates/app-iced/src/platform/locale.rs:86` 的 `reg.exe`。 -//! - **行为**:它只证明「代码里没有绕开构造器」,不证明「桌面上真的没闪窗口」。 -//! 窗口本身的目视验证不在本会话能力内,见 mission 报告。 -//! -//! ## 变异验证(四条,全部实测过并已还原) -//! -//! 1. 往 `download/region_system.rs` 加一处裸 `std::process::Command::new("tzutil")` -//! ⇒ [`no_spawn_site_bypasses_the_quiet_constructor`] 红(退出码 101),报文点名该行 -//! (具体行号随夹具插在哪而变,第一轮 :163、复验 :162)。 -//! 2. 同一处改写成别名形式(`use std::process::Command as Spawn;` + `Spawn::new("tzutil")`) -//! ⇒ 同样红,报文点名 `Spawn::new(...)`。 -//! 3. 删掉 `platform/child.rs` 里那行 `command.creation_flags(CREATE_NO_WINDOW)` -//! ⇒ [`the_constructor_actually_passes_create_no_window`] 红。 -//! 4. 把 `game/java/discover.rs:156` 的 `quiet("reg")` 改回裸 `std::process::Command::new("reg")` -//! ⇒ **必须**红(见下面 P1-1)。 -//! -//! ### P1-1 复发记录:守卫自己被变异验了两遍才对的 -//! -//! 第 3 条第一次跑时**没红**:那版断言搜的是整份源码,而 `quiet` 的文档注释里就写着 -//! `.creation_flags(CREATE_NO_WINDOW)`——注释把断言喂饱了。改成先剥注释([`code_lines`])再判。 -//! -//! 然后第 4 条(复审判的变异 D)**又没红**:那版 `code_lines` 对每一行找 `/*`,找着就当块注释 -//! 开头。可本仓库的文档注释里到处是 glob(`/usr/lib/jvm/*`、`rules.d/*.yaml`)、字符串里也有 -//! (`"mods/*.jar"`),这些 `/*` 没有配对的 `*/`,于是 `in_block` 一旦为真就再也不会变假, -//! **该文件从那一行到 EOF 全部被吞掉**。实测 `crates/core/src` 里 **8/371 个文件**中招,其中 -//! 一个正是 `game/java/discover.rs`——守卫从来没看见过它的 `reg query`。 -//! -//! 现在的 [`code_lines`] 只在**行首**(去掉前导空白)认 `/*`,本仓库零处行首块注释,所以结构上 -//! 不可能再吞代码;[`the_scan_reaches_the_end_of_every_file`] 专门盯住「整份文件被吞掉」这个 -//! 故障(逐文件核对最后一行代码的行号必须出现在扫面结果里)。 -//! -//! 两次都是同一个教训:**守卫本身要被变异验过,而且要验在不止一个文件上**——变异 A/B 落在 -//! `region_system.rs`(那个文件恰好没中毒)才一直是绿的。 +//! 源码扫描型守卫(所有 spawn 是否都经 `quiet`)在 `child_quiet_guard.rs`, +//! 与这里分开是为了让两边都留在 400 行以内,不靠压缩论证换行数。 -use std::fs; -use std::path::{Path, PathBuf}; use std::process::Command; -/// 允许直接构造子进程的**角色**:`(相对 crates/core/src 的路径, 为什么这个角色可以)`。 -/// -/// 这张表是**角色**清单,不是「白名单文件」清单:新增一行必须写清「为什么这一处不能走 -/// `quiet`」。`launch/process.rs` 那条就是全部理由——游戏进程要自己那块控制台。 -const ALLOWED_ROLES: &[(&str, &str)] = &[ - ( - "platform/child.rs", - "构造器自身:`child::quiet` 内部就是这一处 `Command::new`", - ), - ( - "launch/process.rs", - "游戏进程:要 CREATE_NEW_PROCESS_GROUP 撑 taskkill /T 回收进程树;\ - 游戏要不要带自己的控制台窗口是产品决定,不是顺手抑制(M213 明确不动)", - ), -]; - -#[test] -fn no_spawn_site_bypasses_the_quiet_constructor() { - let root = src_root(); - let mut files = Vec::new(); - collect_rust_files(&root, &root, &mut files); - files.sort(); - assert!( - !files.is_empty(), - "没扫到任何 .rs:{} 路径不对", - root.display() - ); - - let mut sites: Vec<(String, usize, String)> = Vec::new(); - for rel in &files { - let text = read_source(&root, rel); - let aliases = command_aliases(&text); - for (number, line) in code_lines(&text) { - if is_spawn_site(line, &aliases) { - sites.push((rel.clone(), number, line.trim().to_string())); - } - } - } - - let illegal: Vec = sites - .iter() - .filter(|(rel, ..)| !ALLOWED_ROLES.iter().any(|(path, _)| path == rel)) - .map(|(rel, number, line)| format!(" src/{rel}:{number} {line}")) - .collect(); - assert!( - illegal.is_empty(), - "发现绕开 `platform::child::quiet` 的子进程构造({} 处):\n{}\n\ - 在 Windows 上这会为每个子进程新建一块控制台(GUI 父进程下就是桌面上「黑框闪一下」)。\n\ - 改走 `crate::platform::child::quiet(program)`;若这一处确实不该抑制窗口,\ - 把它的**角色**加进本文件的 ALLOWED_ROLES 并写清理由。", - illegal.len(), - illegal.join("\n"), - ); - - // 角色核对:允许的角色必须真的存在、且**恰好一处**,否则这张表会悄悄变成摆设。 - for (path, role) in ALLOWED_ROLES { - let hits: Vec = sites - .iter() - .filter(|(rel, ..)| rel == path) - .map(|(.., number, line)| format!(":{number} {line}")) - .collect(); - assert_eq!( - hits.len(), - 1, - "角色 `src/{path}`({role})应当恰好一处直接构造,实测 {} 处:{hits:?}", - hits.len(), - ); - } -} - -#[test] -fn the_constructor_actually_passes_create_no_window() { - let text = read_source(&src_root(), "platform/child.rs"); - // **只看代码行**:`quiet` 的文档注释里就写着 `.creation_flags(CREATE_NO_WINDOW)`, - // 对着整份源码搜会被那句注释喂饱——删掉真正的设置行也照样绿(实测踩过)。 - let code: Vec<&str> = code_lines(&text) - .into_iter() - .map(|(_, line)| line) - .collect(); - // 只去空白,避免格式调整造成假红(下划线是标识符的一部分,不能去)。 - let no_space: String = code - .concat() - .chars() - .filter(|c| !c.is_whitespace()) - .collect(); - assert!( - no_space.contains("creation_flags(CREATE_NO_WINDOW)"), - "`platform::child::quiet` 不再设置 creation_flags —— 抑制窗口的那一行被删了?\n\ - 注意 `.stdout(Stdio::null())` 不能替代它:那只改句柄,控制台窗口照样会被创建。", - ); - // 常量值单独比:数字里的下划线是排版自由,去掉再比。 - let definition = code - .iter() - .find(|line| line.trim_start().starts_with("const CREATE_NO_WINDOW")) - .expect("找不到 `CREATE_NO_WINDOW` 的常量定义"); - let literal: String = definition - .split_once('=') - .expect("常量定义里没有 `=`") - .1 - .chars() - .filter(|c| !c.is_whitespace() && *c != '_') - .collect(); - assert_eq!( - literal.trim_end_matches(';'), - "0x08000000", - "`CREATE_NO_WINDOW` 的取值不对(`{definition}`)。必须是 0x0800_0000,\ - 不是 CREATE_NEW_CONSOLE(0x0000_0010,意思正好相反)。", - ); -} - -#[test] -fn code_lines_is_not_fooled_by_glob_slashes_in_strings_or_docs() { - // M213 复审 P1-1 的最小复现:**字符串**里的 `/*` 不许把后一行吞掉。 - let in_string = code_lines("let a = \"mods/*.jar\";\nlet b = 1;\n"); - assert!( - in_string - .iter() - .any(|(number, line)| *number == 2 && line.contains("let b")), - "字符串里的 `/*` 吞掉了后一行:{in_string:?}", - ); - - // 同一份故障在**文档注释的 glob** 上的形态(`crates/core/src` 里 8 个文件都是这一种)。 - let in_doc_glob = code_lines("//! | Linux | `/usr/lib/jvm/*` |\nfn real_code() {}\n"); - assert_eq!( - in_doc_glob.len(), - 1, - "文档注释里的 glob 吞掉了真代码:{in_doc_glob:?}" - ); - assert!(in_doc_glob[0].1.contains("fn real_code")); - - // 真的块注释还是要被剥掉(别把上面两条修成「块注释一律不认」)。 - let block = code_lines("/* a\n b\n*/\nfn after() {}\n"); - assert_eq!(block.len(), 1, "块注释没被剥:{block:?}"); - assert!(block[0].1.contains("fn after"), "剥错了行:{block:?}"); - - // 同一行闭合的块注释,收尾后的残段是代码。 - let inline_close = code_lines("/* c */ fn inline() {}\n"); - assert_eq!( - inline_close.len(), - 1, - "同行闭合的块注释丢了残段:{inline_close:?}" - ); - assert!(inline_close[0].1.contains("fn inline")); -} - -#[test] -fn the_scan_reaches_the_end_of_every_file() { - // 「整份文件被吞掉」这类故障自己红:逐文件核对**最后一行代码的行号**必须出现在扫面结果里。 - // 这条比「数总行数」尖锐——文件普遍以 `}` 结尾,光比内容会被别处的 `}` 蒙混过去。 - let root = src_root(); - let mut files = Vec::new(); - collect_rust_files(&root, &root, &mut files); - files.sort(); - assert!(!files.is_empty(), "没扫到任何 .rs:{}", root.display()); - - let mut visible_total = 0usize; - let mut line_total = 0usize; - let mut starved: Vec = Vec::new(); - for rel in &files { - let text = read_source(&root, rel); - let visible = code_lines(&text); - visible_total += visible.len(); - line_total += text.lines().count(); - if visible.is_empty() { - starved.push(format!("src/{rel}:整份文件没有一行可见代码")); - continue; - } - let lines: Vec<&str> = text.lines().collect(); - let Some((last_index, last_line)) = lines.iter().enumerate().rev().find(|(_, line)| { - let trimmed = line.trim(); - !trimmed.is_empty() && !trimmed.starts_with("//") - }) else { - starved.push(format!("src/{rel}:整份文件只有注释?")); - continue; - }; - let last_number = last_index + 1; - if !visible.iter().any(|(number, _)| *number == last_number) { - starved.push(format!( - "src/{rel}:最后一行代码 `{}`(第 {last_number} 行)没被扫到 ⇒ 被吞到 EOF", - last_line.trim() - )); - } - } - assert!(starved.is_empty(), "扫面没走到底:\n{}", starved.join("\n")); - - // 兜底:全仓可见行占比实测约 81%,门槛取 60% 留足余量。它只抓「大面积吞行」。 - assert!( - visible_total * 10 >= line_total * 6, - "可见行只有 {visible_total}/{line_total}(<60%),扫面异常缩水", - ); -} - #[test] fn quiet_is_a_plain_command_that_still_captures_output() { // 抑制窗口不能顺手把输出吞了:`.output()` 的管道与退出码必须照旧可用。 @@ -330,117 +83,3 @@ fn shell(scripts: &[&str]) -> Command { command.arg(joined); command } - -fn src_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("src") -} - -fn read_source(root: &Path, rel: &str) -> String { - fs::read_to_string(root.join(rel)).unwrap_or_else(|error| panic!("读不到 src/{rel}:{error}")) -} - -fn collect_rust_files(root: &Path, dir: &Path, out: &mut Vec) { - let entries = - fs::read_dir(dir).unwrap_or_else(|error| panic!("读不到 {}:{error}", dir.display())); - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_rust_files(root, &path, out); - } else if path.extension().is_some_and(|ext| ext == "rs") { - let rel = path.strip_prefix(root).expect("strip_prefix 失败"); - out.push(rel.to_string_lossy().replace('\\', "/")); - } - } -} - -/// 去掉注释后的可见行(行号保留,报错能指到原文件)。 -/// -/// ## `/*` **只在行首**才算块注释开头(M213 复审 P1-1) -/// -/// 上一版对每一行找 `/*`,找着就当块注释开头。可本仓库的文档注释里到处是 glob -/// (`/usr/lib/jvm/*`、`rules.d/*.yaml`、`crash-reports/*.txt`、`.github/workflows/**`), -/// 字符串里也有(`"mods/*.jar"`)。这些 `/*` 没有配对的 `*/`,于是 `in_block` 一旦为真就再也 -/// 不会变假,**该文件从那一行到 EOF 全部被吞掉**——`crates/core/src` 里 8/371 个文件如此, -/// 其中一个正是 `game/java/discover.rs`(它自己有一处 `reg query`)。复审判的变异 D -/// (把它改回裸构造)因此**全绿**:守卫根本没看见那处 spawn。 -/// -/// 现在只在「去掉前导空白后以 `/*` 开头」的行上进入块注释态,行中/字符串里的 `/*` 一律当普通 -/// 代码。本仓库没有任何行首块注释(`grep -rn '^[[:space:]]*/\*' crates/core/src` 零命中), -/// 所以这条状态机在本仓库里实际只走「剥 `//` 整行注释」那一条路,**结构上不可能再吞代码**。 -/// -/// 代价见文件头:行中开始的跨行块注释之后那几行会被当成代码,方向是**误报**。 -fn code_lines(text: &str) -> Vec<(usize, &str)> { - let mut visible: Vec<(usize, &str)> = Vec::new(); - let mut in_block = false; - for (index, raw) in text.lines().enumerate() { - let number = index + 1; - let trimmed = raw.trim_start(); - if in_block { - // 块内只认 `*/`:它后面剩的残段可能是代码(`*/ let x = 1;`)。 - if let Some(end) = trimmed.find("*/") { - in_block = false; - push_tail(&mut visible, number, &trimmed[end + 2..]); - } - continue; - } - if trimmed.starts_with("//") { - continue; - } - if trimmed.starts_with("/*") { - match trimmed.find("*/") { - Some(end) => push_tail(&mut visible, number, &trimmed[end + 2..]), - None => in_block = true, - } - continue; - } - visible.push((number, raw)); - } - visible -} - -/// 块注释收尾后的残段:不是空、也不是新的一条整行注释,才算可见代码。 -fn push_tail<'a>(visible: &mut Vec<(usize, &'a str)>, number: usize, tail: &'a str) { - let tail = tail.trim_start(); - if !tail.is_empty() && !tail.starts_with("//") { - visible.push((number, tail)); - } -} - -/// 本文件里由 `std::process::Command` 导出的别名(`use ... Command as X;` / `type X = …Command;`)。 -fn command_aliases(text: &str) -> Vec { - let mut aliases = Vec::new(); - for (_, line) in code_lines(text) { - let trimmed = line.trim_start(); - // `use std::process::Command as Spawn;` / `use std::process::{Command as Spawn};` - if let Some(at) = line.find("Command as ") { - let ident: String = line[at + "Command as ".len()..] - .chars() - .take_while(|c| c.is_alphanumeric() || *c == '_') - .collect(); - if !ident.is_empty() { - aliases.push(ident); - } - continue; - } - // `type Cmd = std::process::Command;` —— 别名在 `=` 的**左边**。 - let is_command_type_alias = trimmed.starts_with("type ") && line.ends_with("Command;"); - if is_command_type_alias { - if let Some(ident) = trimmed - .split_whitespace() - .find(|word| *word != "type" && !word.contains('=')) - { - aliases.push(ident.to_string()); - } - } - } - aliases -} - -fn is_spawn_site(line: &str, aliases: &[String]) -> bool { - if line.contains("Command::new(") { - return true; - } - aliases - .iter() - .any(|alias| line.contains(&format!("{alias}::new("))) -} diff --git a/crates/core/tests/child_quiet_guard.rs b/crates/core/tests/child_quiet_guard.rs new file mode 100644 index 0000000..6d47b15 --- /dev/null +++ b/crates/core/tests/child_quiet_guard.rs @@ -0,0 +1,370 @@ +//! M213 守卫:`crates/core/src` 里**所有**子进程都必须经 `platform::child::quiet` 起。 +//! +//! ## 它判什么(按角色,不按次数) +//! +//! 唯一的问题是「这一处直接构造子进程**属于哪种角色**、是不是绕开了构造器」。所以判据是: +//! 每一处直接构造的**文件**必须命中 [`ALLOWED_ROLES`] 里的某一条角色;不在其中就是绕开了 +//! `platform::child::quiet`。新加一处裸构造时,红的信息是「某文件某行不属于任何允许角色」, +//! 而不是「总数对不上 N」——所以它咬得住「随手再 spawn 一个命令」这种增量变异。 +//! +//! 另外每个允许角色还钉了**恰好一处**:角色内部长出第二处裸构造同样会红(例如有人在 +//! `platform/child.rs` 里绕开 `quiet` 再写一个 `Command::new`)。 +//! +//! ## 它抓不住什么(写在前面,别把它当完备保证) +//! +//! - **别名类型**:`type Cmd = std::process::Command;` 之后 `Cmd::new(..)`。本文件只认 +//! `Command as <别名>` 与 `type <别名> = ...Command` 这两种写法导出的别名(认得的会拦), +//! 但 `type A = B; type B = Command` 这种两跳的、或从别的模块 `pub use` 进来的类型别名抓不住。 +//! - **不经过 `::new` 的构造**:`std::process::Command` 目前只有 `new` / `default`, +//! 没有 `From`,所以实际风险低;真出现了本文件看不见。 +//! - **只剥「整行注释」**:行尾注释里写 `Command::new` 会**误报**;行中开始的跨行块注释 +//! (`let x = 1; /* …` 续行)之后那几行也会被当成代码,同样是**误报**。 +//! 两个方向都是「宁可红」,与 M213 复审 P1-1 那个假绿方向相反。 +//! - **空白**:判据是字面子串 `Command::new(` / `<别名>::new(`,不做空白归一化, +//! 所以 `Command :: new(..)` 看不见。`rustfmt` 会把它规范回去,只有不经 `rustfmt` +//! 的代码才有这个口子。 +//! - **别的 crate**:`crates/cli` / `crates/presenter` / `crates/app-iced` 也可能 spawn 进程, +//! 本守卫的射程只有 `crates/core/src`(M213 的 scope)。 +//! 复审已确认至少一处漏网:`crates/app-iced/src/platform/locale.rs:86` 的 `reg.exe`。 +//! - **行为**:它只证明「代码里没有绕开构造器」,不证明「桌面上真的没闪窗口」。 +//! 窗口本身的目视验证不在本会话能力内,见 mission 报告。 +//! +//! ## 变异验证(四条,全部实测过并已还原) +//! +//! 1. 往 `download/region_system.rs` 加一处裸 `std::process::Command::new("tzutil")` +//! ⇒ [`no_spawn_site_bypasses_the_quiet_constructor`] 红(退出码 101),报文点名该行 +//! (具体行号随夹具插在哪而变,第一轮 :163、复验 :162)。 +//! 2. 同一处改写成别名形式(`use std::process::Command as Spawn;` + `Spawn::new("tzutil")`) +//! ⇒ 同样红,报文点名 `Spawn::new(...)`。 +//! 3. 删掉 `platform/child.rs` 里那行 `command.creation_flags(CREATE_NO_WINDOW)` +//! ⇒ [`the_constructor_actually_passes_create_no_window`] 红。 +//! 4. 把 `game/java/discover.rs:156` 的 `quiet("reg")` 改回裸 `std::process::Command::new("reg")` +//! ⇒ **必须**红(见下面 P1-1)。 +//! +//! ### P1-1 复发记录:守卫自己被变异验了两遍才对的 +//! +//! 第 3 条第一次跑时**没红**:那版断言搜的是整份源码,而 `quiet` 的文档注释里就写着 +//! `.creation_flags(CREATE_NO_WINDOW)`——注释把断言喂饱了。改成先剥注释([`code_lines`])再判。 +//! +//! 然后第 4 条(复审判的变异 D)**又没红**:那版 `code_lines` 对每一行找 `/*`,找着就当块注释 +//! 开头。可本仓库的文档注释里到处是 glob(`/usr/lib/jvm/*`、`rules.d/*.yaml`)、字符串里也有 +//! (`"mods/*.jar"`),这些 `/*` 没有配对的 `*/`,于是 `in_block` 一旦为真就再也不会变假, +//! **该文件从那一行到 EOF 全部被吞掉**。实测 `crates/core/src` 里 **8/371 个文件**中招,其中 +//! 一个正是 `game/java/discover.rs`——守卫从来没看见过它的 `reg query`。 +//! +//! 现在的 [`code_lines`] 只在**行首**(去掉前导空白)认 `/*`,本仓库零处行首块注释,所以结构上 +//! 不可能再吞代码;[`the_scan_reaches_the_end_of_every_file`] 专门盯住「整份文件被吞掉」这个 +//! 故障(逐文件核对最后一行代码的行号必须出现在扫面结果里)。 +//! +//! 两次都是同一个教训:**守卫本身要被变异验过,而且要验在不止一个文件上**——变异 A/B 落在 +//! `region_system.rs`(那个文件恰好没中毒)才一直是绿的。 + +use std::fs; +use std::path::{Path, PathBuf}; + +/// 允许直接构造子进程的**角色**:`(相对 crates/core/src 的路径, 为什么这个角色可以)`。 +/// +/// 这张表是**角色**清单,不是「白名单文件」清单:新增一行必须写清「为什么这一处不能走 +/// `quiet`」。`launch/process.rs` 那条就是全部理由——游戏进程要自己那块控制台。 +/// `quiet`」。`launch/process.rs` 那条就是全部理由——游戏进程要自己那块控制台。 +const ALLOWED_ROLES: &[(&str, &str)] = &[ + ( + "platform/child.rs", + "构造器自身:`child::quiet` 内部就是这一处 `Command::new`", + ), + ( + "launch/process.rs", + "游戏进程:要 CREATE_NEW_PROCESS_GROUP 撑 taskkill /T 回收进程树;\ + 游戏要不要带自己的控制台窗口是产品决定,不是顺手抑制(M213 明确不动)", + ), +]; + +#[test] +fn no_spawn_site_bypasses_the_quiet_constructor() { + let root = src_root(); + let mut files = Vec::new(); + collect_rust_files(&root, &root, &mut files); + files.sort(); + assert!( + !files.is_empty(), + "没扫到任何 .rs:{} 路径不对", + root.display() + ); + + let mut sites: Vec<(String, usize, String)> = Vec::new(); + for rel in &files { + let text = read_source(&root, rel); + let aliases = command_aliases(&text); + for (number, line) in code_lines(&text) { + if is_spawn_site(line, &aliases) { + sites.push((rel.clone(), number, line.trim().to_string())); + } + } + } + + let illegal: Vec = sites + .iter() + .filter(|(rel, ..)| !ALLOWED_ROLES.iter().any(|(path, _)| path == rel)) + .map(|(rel, number, line)| format!(" src/{rel}:{number} {line}")) + .collect(); + assert!( + illegal.is_empty(), + "发现绕开 `platform::child::quiet` 的子进程构造({} 处):\n{}\n\ + 在 Windows 上这会为每个子进程新建一块控制台(GUI 父进程下就是桌面上「黑框闪一下」)。\n\ + 改走 `crate::platform::child::quiet(program)`;若这一处确实不该抑制窗口,\ + 把它的**角色**加进本文件的 ALLOWED_ROLES 并写清理由。", + illegal.len(), + illegal.join("\n"), + ); + + // 角色核对:允许的角色必须真的存在、且**恰好一处**,否则这张表会悄悄变成摆设。 + for (path, role) in ALLOWED_ROLES { + let hits: Vec = sites + .iter() + .filter(|(rel, ..)| rel == path) + .map(|(.., number, line)| format!(":{number} {line}")) + .collect(); + assert_eq!( + hits.len(), + 1, + "角色 `src/{path}`({role})应当恰好一处直接构造,实测 {} 处:{hits:?}", + hits.len(), + ); + } +} + +#[test] +fn the_constructor_actually_passes_create_no_window() { + let text = read_source(&src_root(), "platform/child.rs"); + // **只看代码行**:`quiet` 的文档注释里就写着 `.creation_flags(CREATE_NO_WINDOW)`, + // 对着整份源码搜会被那句注释喂饱——删掉真正的设置行也照样绿(实测踩过)。 + let code: Vec<&str> = code_lines(&text) + .into_iter() + .map(|(_, line)| line) + .collect(); + // 只去空白,避免格式调整造成假红(下划线是标识符的一部分,不能去)。 + let no_space: String = code + .concat() + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + assert!( + no_space.contains("creation_flags(CREATE_NO_WINDOW)"), + "`platform::child::quiet` 不再设置 creation_flags —— 抑制窗口的那一行被删了?\n\ + 注意 `.stdout(Stdio::null())` 不能替代它:那只改句柄,控制台窗口照样会被创建。", + ); + // 常量值单独比:数字里的下划线是排版自由,去掉再比。 + let definition = code + .iter() + .find(|line| line.trim_start().starts_with("const CREATE_NO_WINDOW")) + .expect("找不到 `CREATE_NO_WINDOW` 的常量定义"); + let literal: String = definition + .split_once('=') + .expect("常量定义里没有 `=`") + .1 + .chars() + .filter(|c| !c.is_whitespace() && *c != '_') + .collect(); + assert_eq!( + literal.trim_end_matches(';'), + "0x08000000", + "`CREATE_NO_WINDOW` 的取值不对(`{definition}`)。必须是 0x0800_0000,\ + 不是 CREATE_NEW_CONSOLE(0x0000_0010,意思正好相反)。", + ); +} + +#[test] +fn code_lines_is_not_fooled_by_glob_slashes_in_strings_or_docs() { + // M213 复审 P1-1 的最小复现:**字符串**里的 `/*` 不许把后一行吞掉。 + let in_string = code_lines("let a = \"mods/*.jar\";\nlet b = 1;\n"); + assert!( + in_string + .iter() + .any(|(number, line)| *number == 2 && line.contains("let b")), + "字符串里的 `/*` 吞掉了后一行:{in_string:?}", + ); + + // 同一份故障在**文档注释的 glob** 上的形态(`crates/core/src` 里 8 个文件都是这一种)。 + let in_doc_glob = code_lines("//! | Linux | `/usr/lib/jvm/*` |\nfn real_code() {}\n"); + assert_eq!( + in_doc_glob.len(), + 1, + "文档注释里的 glob 吞掉了真代码:{in_doc_glob:?}" + ); + assert!(in_doc_glob[0].1.contains("fn real_code")); + + // 真的块注释还是要被剥掉(别把上面两条修成「块注释一律不认」)。 + let block = code_lines("/* a\n b\n*/\nfn after() {}\n"); + assert_eq!(block.len(), 1, "块注释没被剥:{block:?}"); + assert!(block[0].1.contains("fn after"), "剥错了行:{block:?}"); + + // 同一行闭合的块注释,收尾后的残段是代码。 + let inline_close = code_lines("/* c */ fn inline() {}\n"); + assert_eq!( + inline_close.len(), + 1, + "同行闭合的块注释丢了残段:{inline_close:?}" + ); + assert!(inline_close[0].1.contains("fn inline")); +} + +#[test] +fn the_scan_reaches_the_end_of_every_file() { + // 「整份文件被吞掉」这类故障自己红:逐文件核对**最后一行代码的行号**必须出现在扫面结果里。 + // 这条比「数总行数」尖锐——文件普遍以 `}` 结尾,光比内容会被别处的 `}` 蒙混过去。 + let root = src_root(); + let mut files = Vec::new(); + collect_rust_files(&root, &root, &mut files); + files.sort(); + assert!(!files.is_empty(), "没扫到任何 .rs:{}", root.display()); + + let mut visible_total = 0usize; + let mut line_total = 0usize; + let mut starved: Vec = Vec::new(); + for rel in &files { + let text = read_source(&root, rel); + let visible = code_lines(&text); + visible_total += visible.len(); + line_total += text.lines().count(); + if visible.is_empty() { + starved.push(format!("src/{rel}:整份文件没有一行可见代码")); + continue; + } + let lines: Vec<&str> = text.lines().collect(); + let Some((last_index, last_line)) = lines.iter().enumerate().rev().find(|(_, line)| { + let trimmed = line.trim(); + !trimmed.is_empty() && !trimmed.starts_with("//") + }) else { + starved.push(format!("src/{rel}:整份文件只有注释?")); + continue; + }; + let last_number = last_index + 1; + if !visible.iter().any(|(number, _)| *number == last_number) { + starved.push(format!( + "src/{rel}:最后一行代码 `{}`(第 {last_number} 行)没被扫到 ⇒ 被吞到 EOF", + last_line.trim() + )); + } + } + assert!(starved.is_empty(), "扫面没走到底:\n{}", starved.join("\n")); + + // 兜底:全仓可见行占比实测约 81%,门槛取 60% 留足余量。它只抓「大面积吞行」。 + assert!( + visible_total * 10 >= line_total * 6, + "可见行只有 {visible_total}/{line_total}(<60%),扫面异常缩水", + ); +} + +fn src_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("src") +} + +fn read_source(root: &Path, rel: &str) -> String { + fs::read_to_string(root.join(rel)).unwrap_or_else(|error| panic!("读不到 src/{rel}:{error}")) +} + +fn collect_rust_files(root: &Path, dir: &Path, out: &mut Vec) { + let entries = + fs::read_dir(dir).unwrap_or_else(|error| panic!("读不到 {}:{error}", dir.display())); + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rust_files(root, &path, out); + } else if path.extension().is_some_and(|ext| ext == "rs") { + let rel = path.strip_prefix(root).expect("strip_prefix 失败"); + out.push(rel.to_string_lossy().replace('\\', "/")); + } + } +} + +/// 去掉注释后的可见行(行号保留,报错能指到原文件)。 +/// +/// ## `/*` **只在行首**才算块注释开头(M213 复审 P1-1) +/// +/// 上一版对每一行找 `/*`,找着就当块注释开头。可本仓库的文档注释里到处是 glob +/// (`/usr/lib/jvm/*`、`rules.d/*.yaml`、`crash-reports/*.txt`、`.github/workflows/**`), +/// 字符串里也有(`"mods/*.jar"`)。这些 `/*` 没有配对的 `*/`,于是 `in_block` 一旦为真就再也 +/// 不会变假,**该文件从那一行到 EOF 全部被吞掉**——`crates/core/src` 里 8/371 个文件如此, +/// 其中一个正是 `game/java/discover.rs`(它自己有一处 `reg query`)。复审判的变异 D +/// (把它改回裸构造)因此**全绿**:守卫根本没看见那处 spawn。 +/// +/// 现在只在「去掉前导空白后以 `/*` 开头」的行上进入块注释态,行中/字符串里的 `/*` 一律当普通 +/// 代码。本仓库没有任何行首块注释(`grep -rn '^[[:space:]]*/\*' crates/core/src` 零命中), +/// 所以这条状态机在本仓库里实际只走「剥 `//` 整行注释」那一条路,**结构上不可能再吞代码**。 +/// +/// 代价见文件头:行中开始的跨行块注释之后那几行会被当成代码,方向是**误报**。 +fn code_lines(text: &str) -> Vec<(usize, &str)> { + let mut visible: Vec<(usize, &str)> = Vec::new(); + let mut in_block = false; + for (index, raw) in text.lines().enumerate() { + let number = index + 1; + let trimmed = raw.trim_start(); + if in_block { + // 块内只认 `*/`:它后面剩的残段可能是代码(`*/ let x = 1;`)。 + if let Some(end) = trimmed.find("*/") { + in_block = false; + push_tail(&mut visible, number, &trimmed[end + 2..]); + } + continue; + } + if trimmed.starts_with("//") { + continue; + } + if trimmed.starts_with("/*") { + match trimmed.find("*/") { + Some(end) => push_tail(&mut visible, number, &trimmed[end + 2..]), + None => in_block = true, + } + continue; + } + visible.push((number, raw)); + } + visible +} + +/// 块注释收尾后的残段:不是空、也不是新的一条整行注释,才算可见代码。 +fn push_tail<'a>(visible: &mut Vec<(usize, &'a str)>, number: usize, tail: &'a str) { + let tail = tail.trim_start(); + if !tail.is_empty() && !tail.starts_with("//") { + visible.push((number, tail)); + } +} + +/// 本文件里由 `std::process::Command` 导出的别名(`use ... Command as X;` / `type X = …Command;`)。 +fn command_aliases(text: &str) -> Vec { + let mut aliases = Vec::new(); + for (_, line) in code_lines(text) { + let trimmed = line.trim_start(); + // `use std::process::Command as Spawn;` / `use std::process::{Command as Spawn};` + if let Some(at) = line.find("Command as ") { + let ident: String = line[at + "Command as ".len()..] + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !ident.is_empty() { + aliases.push(ident); + } + continue; + } + // `type Cmd = std::process::Command;` —— 别名在 `=` 的**左边**。 + let is_command_type_alias = trimmed.starts_with("type ") && line.ends_with("Command;"); + if is_command_type_alias { + if let Some(ident) = trimmed + .split_whitespace() + .find(|word| *word != "type" && !word.contains('=')) + { + aliases.push(ident.to_string()); + } + } + } + aliases +} + +fn is_spawn_site(line: &str, aliases: &[String]) -> bool { + if line.contains("Command::new(") { + return true; + } + aliases + .iter() + .any(|alias| line.contains(&format!("{alias}::new("))) +}