Files
Unlucky-Launcher/crates/core/tests/child_quiet_guard.rs
T
AskaEth 5959a27add
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
fix(core): child_quiet.rs 446 行越 400 红线,按职责拆成两个测试文件
M213 r2 把守卫的剥注释+两条新用例都塞进同一个文件,涨到 446 行。tower 的合并门里
400 行扫描算出了 OVER=1,但 push 写在扫描之后没有用它拦住自己,所以 dev 上先红了一次。

- child_quiet.rs(85):quiet 的**行为**测试(管道/退出码/env/错误语义)
- child_quiet_guard.rs(370):源码扫描型守卫(角色表 + 4 条变异对应的用例 + 解析 helper)

拆分后复验:两文件 5+4=9 条全绿;变异 D(discover.rs:156 改裸构造)仍 exit 101 并点名该行
——守卫的牙没在拆分中丢掉。
2026-11-09 15:14:23 +08:00

371 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String> = 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<String> = 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<String> = 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<String>) {
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<String> {
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(")))
}