Merge branch 'feat/m213-consolesilence2-quiet-child-process'
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
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
This commit is contained in:
commit
aa8c8f506a
17 files changed
+544
-39
No files matched your search
@@ -16,7 +16,6 @@
|
||||
//! 既不误杀真实装,也不假装校验通过。
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -31,6 +30,7 @@ use crate::download::verify;
|
||||
use crate::download::HashAlgo;
|
||||
use crate::game::java::{self, JavaRequirement, JavaRuntime};
|
||||
use crate::launch::redact::Redactor;
|
||||
use crate::platform::child::quiet;
|
||||
|
||||
/// 一步的结局。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -227,7 +227,7 @@ fn run_step(
|
||||
let classpath = step.classpath_join()?;
|
||||
|
||||
let started = Instant::now();
|
||||
let output = Command::new(&runtime.binary)
|
||||
let output = quiet(&runtime.binary)
|
||||
.arg("-cp")
|
||||
.arg(&classpath)
|
||||
.arg(&main_class)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! 两个纪律:①**不引依赖**——`tzutil` / `reg` 都是系统自带,`discover.rs` 早就有 `reg query`
|
||||
//! 的先例;②**不做网络探测**——探测(google 可达性)仍由调用方决定开不开。
|
||||
|
||||
use std::process::Command;
|
||||
use crate::platform::child::quiet;
|
||||
|
||||
use super::region::{Signals, GREATER_CHINA_TIMEZONES};
|
||||
|
||||
@@ -64,7 +64,7 @@ pub fn detect_signals() -> Signals {
|
||||
/// `tzutil /g` 的输出(失败为 `None`)。
|
||||
#[must_use]
|
||||
pub fn windows_timezone() -> Option<String> {
|
||||
let output = Command::new("tzutil").arg("/g").output().ok()?;
|
||||
let output = quiet("tzutil").arg("/g").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ pub fn windows_timezone() -> Option<String> {
|
||||
/// 系统的 locale 名(`HKCU\Control Panel\International` 的 `LocaleName`)。
|
||||
#[must_use]
|
||||
pub fn windows_locale() -> Option<String> {
|
||||
let output = Command::new("reg")
|
||||
let output = quiet("reg")
|
||||
.args([
|
||||
"query",
|
||||
"HKCU\\Control Panel\\International",
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
//! (别为了一次启动白跑 N 次 `java -version`)。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::platform::child::quiet;
|
||||
use crate::platform::paths::{EnvSource, ProcessEnv};
|
||||
use crate::platform::termux;
|
||||
|
||||
@@ -153,7 +153,7 @@ pub fn registry_homes() -> Vec<PathBuf> {
|
||||
}
|
||||
let mut homes = Vec::new();
|
||||
for view in ["64", "32"] {
|
||||
let Ok(output) = Command::new("reg")
|
||||
let Ok(output) = quiet("reg")
|
||||
.args(["query", "HKLM\\SOFTWARE\\JavaSoft\\JDK", "/s", "/reg:"])
|
||||
.arg(view)
|
||||
.output()
|
||||
|
||||
@@ -105,7 +105,7 @@ pub fn probe_with_timeout(
|
||||
cancelled: &dyn Fn() -> bool,
|
||||
) -> (JavaRuntime, String) {
|
||||
let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin);
|
||||
let mut command = std::process::Command::new(binary);
|
||||
let mut command = crate::platform::child::quiet(binary);
|
||||
command.arg("-version");
|
||||
let Some(output) = super::proc::run_with_timeout(command, timeout, cancelled) else {
|
||||
return (runtime, String::new());
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
//! 候选运行时由调用方注入,测试因此不需要真的装三个 JDK。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use super::extract::{java_binary_name, read_stamp};
|
||||
use super::vendor::JdkVendor;
|
||||
use crate::platform::child::quiet;
|
||||
|
||||
/// 缺省 Java 主版本(探测不到任何东西时的保守值,D13:JDK 17 基准)。
|
||||
pub const DEFAULT_MAJOR: u32 = 17;
|
||||
@@ -119,7 +119,7 @@ impl JavaRuntime {
|
||||
#[must_use]
|
||||
pub fn probe(binary: &Path, origin: JavaOrigin) -> JavaRuntime {
|
||||
let mut runtime = JavaRuntime::unknown(binary.to_path_buf(), origin);
|
||||
let Ok(output) = Command::new(binary).arg("-version").output() else {
|
||||
let Ok(output) = quiet(binary).arg("-version").output() else {
|
||||
return runtime;
|
||||
};
|
||||
let mut text = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
|
||||
@@ -236,7 +236,7 @@ mod tests {
|
||||
seconds: u32,
|
||||
) -> std::process::Command {
|
||||
let mut command = if cfg!(windows) {
|
||||
let mut command = std::process::Command::new("powershell");
|
||||
let mut command = crate::platform::child::quiet("powershell");
|
||||
command.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
@@ -248,7 +248,7 @@ mod tests {
|
||||
]);
|
||||
command
|
||||
} else {
|
||||
let mut command = std::process::Command::new("sh");
|
||||
let mut command = crate::platform::child::quiet("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
&format!(
|
||||
@@ -267,11 +267,11 @@ mod tests {
|
||||
/// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。
|
||||
fn sleeping_command(seconds: u32) -> std::process::Command {
|
||||
if cfg!(windows) {
|
||||
let mut command = std::process::Command::new("cmd");
|
||||
let mut command = crate::platform::child::quiet("cmd");
|
||||
command.args(["/C", &format!("ping -n {seconds} 127.0.0.1 > NUL")]);
|
||||
command
|
||||
} else {
|
||||
let mut command = std::process::Command::new("sleep");
|
||||
let mut command = crate::platform::child::quiet("sleep");
|
||||
command.arg(seconds.to_string());
|
||||
command
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
//! 镜像顺序是实测可用的国内源;全部失败才报错,错误里带每一跳的原因。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::platform::child::quiet;
|
||||
use crate::platform::paths::ProcessEnv;
|
||||
use crate::platform::termux as host;
|
||||
|
||||
@@ -183,7 +183,7 @@ fn apt_install(prefix: &Path, workspace: &Path, mirror: &str, package: &str) ->
|
||||
/// 执行一次 `apt-get`(`-o` 选项在前,子命令在后)。
|
||||
fn run_apt(prefix: &Path, options: &[String], args: &[&str]) -> Result<(), String> {
|
||||
let apt = prefix.join("bin").join("apt-get");
|
||||
let mut command = Command::new(&apt);
|
||||
let mut command = quiet(&apt);
|
||||
command.env("DEBIAN_FRONTEND", "noninteractive");
|
||||
for option in options {
|
||||
command.arg("-o").arg(option);
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
//! 的实际停止流程以“进程是否真的消失”为准(`try_wait` 轮询),本模块只提供发送动作。
|
||||
|
||||
use std::io;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::platform::child::quiet;
|
||||
|
||||
/// 温和关闭(Windows 无 `/F`;POSIX `SIGTERM`)。
|
||||
pub fn terminate(pid: u32) -> io::Result<()> {
|
||||
@@ -100,7 +101,7 @@ fn pgid_of(pid: u32) -> Option<u32> {
|
||||
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
fn pgid_of(pid: u32) -> Option<u32> {
|
||||
let output = Command::new("ps")
|
||||
let output = quiet("ps")
|
||||
.args(["-o", "pgid=", "-p", &pid.to_string()])
|
||||
.output()
|
||||
.ok()?;
|
||||
@@ -113,18 +114,14 @@ fn pgid_of(pid: u32) -> Option<u32> {
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
/// 不弹控制台窗口(否则 kill 子进程会闪一个黑框)。
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
pub(super) fn signal(pid: u32, force: bool) -> io::Result<()> {
|
||||
let mut command = Command::new("taskkill");
|
||||
// `CREATE_NO_WINDOW` 由 `platform::child::quiet` 统一带(否则 kill 子进程会闪一个黑框)。
|
||||
let mut command = quiet("taskkill");
|
||||
command.arg("/PID").arg(pid.to_string()).arg("/T");
|
||||
if force {
|
||||
command.arg("/F");
|
||||
}
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
let status = command
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
@@ -168,7 +165,7 @@ mod imp {
|
||||
/// `kill` 不存在时给**明确**错误而不是静默失败:否则调用方只会看到“进程仍在运行”,
|
||||
/// 排查方向会被带偏(M48 之前的红灯就是这么来的)。
|
||||
fn run_kill(flag: &str, args: &[&str]) -> io::Result<bool> {
|
||||
match Command::new("kill")
|
||||
match quiet("kill")
|
||||
.arg(flag)
|
||||
.args(args)
|
||||
.stdout(std::process::Stdio::null())
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 子进程构造器:本 crate 里**唯一**允许直接 `std::process::Command::new` 的地方(M213)。
|
||||
//!
|
||||
//! ## 为什么需要它(这不是风格问题,是窗口问题)
|
||||
//!
|
||||
//! Windows 上「某个子进程要不要一块控制台」由**创建者的可执行文件类型**决定:一个**自己没有
|
||||
//! 控制台**的进程(GUI 程序的 PE Subsystem = 2;本仓库的 `unlucky-launcher.exe` 就是,M196 已改)
|
||||
//! 去 spawn 一个**控制台程序**(`java.exe` / `reg.exe` / `tzutil.exe` / `cmd.exe` /
|
||||
//! `powershell.exe` …)时,Windows 会为这个子进程**新建一块控制台**——桌面上看到的就是
|
||||
//! 「黑框闪一下」。所以探 N 个 JDK 就闪 N 次 `java -version`,用户的原话是「疯狂闪」。
|
||||
//!
|
||||
//! 抑制手段只有一个:创建时带 `CREATE_NO_WINDOW`(`0x0800_0000`)。
|
||||
//! **`Stdio::null()` 不管这件事**——它只把子进程的 stdout/stderr 指到空设备,控制台窗口照样会被
|
||||
//! 创建。所以到处补 `.stdout(Stdio::null())` 是无效修法。
|
||||
//!
|
||||
//! ## 谁**不许**走这里
|
||||
//!
|
||||
//! **游戏进程**([`crate::launch::process::RunningProcess::spawn`])保留自己的
|
||||
//! `CREATE_NEW_PROCESS_GROUP`(`0x0000_0200`),以便 `taskkill /T` 递归回收整棵进程树。
|
||||
//!
|
||||
//! 这两个 flag 在 **Win32 层是可以任意组合的**:[Process Creation Flags][flags] 原文
|
||||
//! 「They can be specified in any combination, except as noted」,而两条各自 not 掉的只有
|
||||
//! `CREATE_NEW_CONSOLE` / `DETACHED_PROCESS`,与这一组无关。真正的坑在 **Rust std 这一层**:
|
||||
//! `CommandExt::creation_flags` 是 **setter 不是累加器**——「先 `quiet()` 再
|
||||
//! `.creation_flags(CREATE_NEW_PROCESS_GROUP)`」会把 `CREATE_NO_WINDOW` **静默覆盖掉**。
|
||||
//!
|
||||
//! 而「游戏要不要带自己的控制台窗口」是**产品决定**(玩家要看游戏自己那段输出),不是顺手
|
||||
//! 抑制的事——所以这里不复用 `quiet`,显式只设 group flag,M213 一个字没动它。
|
||||
//!
|
||||
//! 那边是唯一另一处合法的直接构造点,两处都由 `crates/core/tests/child_quiet.rs` 的守卫钉住。
|
||||
//!
|
||||
//! [flags]: https://learn.microsoft.com/windows/win32/procthread/process-creation-flags
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::process::Command;
|
||||
|
||||
/// `CREATE_NO_WINDOW`:子进程不获得控制台窗口。
|
||||
///
|
||||
/// 见 <https://learn.microsoft.com/windows/win32/procthread/process-creation-flags>。
|
||||
/// 别和 `CREATE_NEW_CONSOLE`(`0x0000_0010`,意思正好相反)或
|
||||
/// `DETACHED_PROCESS`(`0x0000_0008`,子进程不继承父控制台、也不新建;标准句柄由
|
||||
/// `STARTUPINFO` 给,给了照样可用)搞混。
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// 起一个**不该在桌面上留下窗口**的子进程。
|
||||
///
|
||||
/// - Windows:`.creation_flags(CREATE_NO_WINDOW)`;
|
||||
/// - 其它平台:原样返回,一个字不改(POSIX 没有「spawn 就新开一个终端」这回事,子进程的
|
||||
/// 窗口从来不由我们造)。
|
||||
///
|
||||
/// 返回值就是普通 [`std::process::Command`],调用方照旧 `.arg(..)` / `.env(..)` /
|
||||
/// `.output()` / `.status()`,或者整个交给 `game::java::proc::run_with_timeout` 去带超时跑。
|
||||
/// 本 crate 是 `#![forbid(unsafe_code)]`,而 `CommandExt::creation_flags` 是 std 的安全 API,
|
||||
/// 所以这里不需要任何 `unsafe`。
|
||||
#[must_use]
|
||||
pub fn quiet<S: AsRef<OsStr>>(program: S) -> Command {
|
||||
#[allow(unused_mut)]
|
||||
let mut command = Command::new(program);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
command
|
||||
}
|
||||
@@ -216,8 +216,7 @@ pub fn format_version(version: (u32, u32)) -> String {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run(program: &str, args: &[&str]) -> Option<String> {
|
||||
use std::process::Command;
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
let output = super::child::quiet(program).args(args).output().ok()?;
|
||||
if output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -91,8 +91,7 @@ pub fn parse_sysctl_bool(text: &str) -> bool {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn run(program: &str, args: &[&str]) -> Option<String> {
|
||||
use std::process::Command;
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
let output = super::child::quiet(program).args(args).output().ok()?;
|
||||
if !output.status.success() || output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -24,11 +24,15 @@
|
||||
//! - [`wallpaper`]:桌面壁纸**路径探测**(D38 配色三级优先里第②级的输入)。本模块只做路径
|
||||
//! 探测——不解码图片、不取色、不生成调色板,莫奈取色与三级降级在 `crates/design` 的
|
||||
//! 后续 mission。
|
||||
//! - [`child`]:子进程的唯一构造器 [`child::quiet`](M213)。Windows 上给子进程带
|
||||
//! `CREATE_NO_WINDOW`,否则 GUI 父进程 spawn 控制台程序会给子进程新建控制台——桌面上
|
||||
//! 就是「黑框闪一下」。**别在别处直接构造 `Command`**,`tests/child_quiet.rs` 守门。
|
||||
//!
|
||||
//! 尚未落地(后续 mission):文件锁、进程工具、真实 GL/Vulkan 能力探测(钩子见
|
||||
//! [`linux_caps::probe_gl`] 与 [`vk_probe::probe_vk`],FFI 受限,实际探测归 app 层渲染后端选择)。
|
||||
|
||||
pub mod atomic;
|
||||
pub mod child;
|
||||
pub mod data_dir;
|
||||
pub mod dir_migration;
|
||||
pub mod error;
|
||||
|
||||
@@ -173,8 +173,7 @@ fn parse_dword(raw: &str) -> Option<u32> {
|
||||
|
||||
/// 跑一个外部命令,取 stdout(命令缺失 / 非零退出 / 空输出都返回 `None`)。
|
||||
fn run(program: &str, args: &[&str]) -> Option<String> {
|
||||
use std::process::Command;
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
let output = super::child::quiet(program).args(args).output().ok()?;
|
||||
if output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -296,8 +296,7 @@ pub fn ldconfig_has_libvulkan(text: &str) -> bool {
|
||||
|
||||
/// 跑一个只读命令取 stdout(`reg query` / `ldconfig -p`;失败返回 `None`)。
|
||||
fn run(program: &str, args: &[&str]) -> Option<String> {
|
||||
use std::process::Command;
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
let output = super::child::quiet(program).args(args).output().ok()?;
|
||||
if output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::platform::child::quiet;
|
||||
use crate::platform::paths::EnvSource;
|
||||
|
||||
/// 探测需要的外部世界:四个方法覆盖三平台的全部读法。
|
||||
@@ -62,10 +63,7 @@ impl ProbeIo for HostIo {
|
||||
}
|
||||
|
||||
fn run(&self, program: &str, args: &[&str]) -> Option<String> {
|
||||
let output = std::process::Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.ok()?;
|
||||
let output = quiet(program).args(args).output().ok()?;
|
||||
// `Command::output` 对非零退出同样返回 `Ok`,所以退出码要单独判:三条命令
|
||||
// (`reg.exe` / `gsettings` / `defaults`)出错时都走 stderr,只看 stdout 会把
|
||||
// 「查不到」和「命令失败」混成一种,调用方的原因文案就名不副实了。
|
||||
|
||||
@@ -206,8 +206,7 @@ fn parse_version_pair(value: &str) -> Option<(u32, u32)> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run(program: &str, args: &[&str]) -> Option<String> {
|
||||
use std::process::Command;
|
||||
let output = Command::new(program).args(args).output().ok()?;
|
||||
let output = super::child::quiet(program).args(args).output().ok()?;
|
||||
if output.stdout.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
//! 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};
|
||||
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 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%),扫面异常缩水",
|
||||
);
|
||||
}
|
||||
|
||||
#[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('\\', "/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 去掉注释后的可见行(行号保留,报错能指到原文件)。
|
||||
///
|
||||
/// ## `/*` **只在行首**才算块注释开头(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(")))
|
||||
}
|
||||
Reference in new issue
Block a user