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)
  都实测变红并已还原。
This commit is contained in:
AskaEth committed 2026-11-09 15:07:56 +08:00
1 parent bb8be87c8f
commit 40a296471d
17 files changed
+417 -39

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
//! 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(")))
}