Files
Unlucky-Launcher/crates/core/tests/child_quiet.rs
T
AskaEth 40a296471d fix(core): 所有子进程统一走 platform::child::quiet + 角色守卫(M213)
GUI 父进程(PE Subsystem = 2)spawn 控制台子进程时,Windows 会为子进程新建一块
控制台——桌面上就是「黑框闪一下」。改前 crates/core/src 有 21 处 Command::new,
只有 tree_kill 那一处带了 CREATE_NO_WINDOW,于是探 N 个 JDK 就闪 N 次 java -version。

- 新增 crates/core/src/platform/child.rs:唯一构造器 quiet(program),Windows 上统一
  .creation_flags(CREATE_NO_WINDOW),其它平台原样返回。tree_kill 里那份就地合并进来,
  不再保留第二份常量。
- 20 处 spawn 全部改走它(region_system / java{discover,manual,probe,proc,termux} /
  addon exec / platform{linux_caps,macos_version,theme_probe,vk_probe,windows_version,
  wallpaper/io})。
- launch/process.rs 零改动:游戏进程要 CREATE_NEW_PROCESS_GROUP 撑 taskkill /T,
  带不带控制台窗口是产品决定。
- crates/core/tests/child_quiet.rs:按角色判「有没有绕开构造器」的守卫,外加构造器
  本身的 4 条行为用例。三条变异(裸 Command::new / 别名 Spawn::new / 删掉 creation_flags)
  都实测变红并已还原。
2026-11-09 15:07:56 +08:00

330 lines
13 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` 会**误报**
//! (方向安全:宁可红),因为这仓库的文档注释都是整行 `//` 风格,没必要为它写字符串解析。
//! - **别的 crate**:`crates/cli` / `crates/presenter` / `crates/app-iced` 也可能 spawn 进程,
//! 本守卫的射程只有 `crates/core/src`(M213 的 scope)。
//! - **行为**:它只证明「代码里没有绕开构造器」,不证明「桌面上真的没闪窗口」。
//! 窗口本身的目视验证不在本会话能力内,见 mission 报告。
//!
//! ## 变异验证(三条,全部实测过并已还原)
//!
//! 1. 往 `download/region_system.rs` 加一处裸 `std::process::Command::new("tzutil")`
//! ⇒ [`no_spawn_site_bypasses_the_quiet_constructor`] 红,报文点名
//! `src/download/region_system.rs:163`(退出码 101)。
//! 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`] 红。
//!
//! 第 3 条第一次跑时**没红**:那版断言搜的是整份源码,而 `quiet` 的文档注释里就写着
//! `.creation_flags(CREATE_NO_WINDOW)`——注释把断言喂饱了。现在两个测试都先剥注释
//! ([`code_lines`])再判,重跑才红。这条教训写在这里:**守卫本身也要被变异验过**,
//! 否则它守的是注释。
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<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 quiet_is_a_plain_command_that_still_captures_output() {
// 抑制窗口不能顺手把输出吞了:`.output()` 的管道与退出码必须照旧可用。
let output = shell(&["echo hi"]).output().expect("spawn 失败");
assert!(output.status.success(), "退出码:{}", output.status);
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "hi");
}
#[test]
fn quiet_keeps_stderr_and_nonzero_exit_code() {
let output = shell(&["echo oops 1>&2", "exit 3"])
.output()
.expect("spawn 失败");
assert_eq!(output.status.code(), Some(3), "退出码没透传");
assert!(
String::from_utf8_lossy(&output.stderr).contains("oops"),
"stderr 没被捕获:{:?}",
String::from_utf8_lossy(&output.stderr),
);
}
#[test]
fn quiet_keeps_env_and_args() {
// 走 argv 与 env 两条通道,证明 `quiet` 没把 Command 包装成别的东西。
let script = if cfg!(windows) {
"echo %ULL_QUIET_PROBE%"
} else {
"echo $ULL_QUIET_PROBE"
};
let output = shell(&[script])
.env("ULL_QUIET_PROBE", "m213")
.output()
.expect("spawn 失败");
assert!(output.status.success(), "退出码:{}", output.status);
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"m213",
"参数/环境变量在构造器里丢了",
);
}
#[test]
fn quiet_does_not_swallow_spawn_errors() {
// 找不到程序时仍然是 Err:构造器不加任何「失败即静默」的逻辑。
let error = unlucky_core::platform::child::quiet("ull-no-such-program-m213")
.output()
.expect_err("不存在的程序居然 spawn 成功了?");
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
}
/// 构造器的返回值必须能直接当 `std::process::Command` 用(下游 `run_with_timeout` 就是这么接的)。
#[test]
fn quiet_returns_a_std_command() {
fn takes_command(_: Command) {}
takes_command(unlucky_core::platform::child::quiet("ull-type-check-only"));
}
/// 走构造器起一个 shell,拼上本平台的「跑一段脚本」参数。
fn shell(scripts: &[&str]) -> Command {
let (program, flag) = if cfg!(windows) {
("cmd", "/C")
} else {
("sh", "-c")
};
let mut command = unlucky_core::platform::child::quiet(program);
command.arg(flag);
// Windows 的 `cmd /C` 只吃一条命令行;POSIX 的 `sh -c` 同理,所以用 `&` / `;` 串起来。
let joined = if cfg!(windows) {
scripts.join(" & ")
} else {
scripts.join("; ")
};
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<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('\\', "/"));
}
}
}
/// 去掉注释后的可见行(行号保留,报错能指到原文件)。
///
/// 只剥「整行注释」与 `/* … */` 块;行尾注释不剥(见文件头的「抓不住什么」)。
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 mut rest = raw;
loop {
if in_block {
match rest.find("*/") {
Some(end) => {
rest = &rest[end + 2..];
in_block = false;
}
None => break,
}
}
match rest.find("/*") {
Some(start) => {
visible.push((number, &rest[..start]));
rest = &rest[start + 2..];
in_block = true;
}
None => {
visible.push((number, rest));
break;
}
}
}
}
visible
.into_iter()
.filter(|(_, line)| !line.trim_start().starts_with("//"))
.collect()
}
/// 本文件里由 `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(")))
}