Files
Unlucky-Launcher/packaging/check_pe_subsystem.py
T
tower-worker 3fd33a672f fix(m196): r1 复审 P1(非 Windows dead_code)+ P2 四条
P1(阻断):console.rs 的 `INVALID_HANDLE_VALUE` 只被 `#[cfg(target_os = "windows")]`
的 usable() 用到,而这个模块在 Linux/macOS 上也编译(platform/mod.rs 的 pub mod win32;
没有 cfg)⇒ ubuntu 的 `clippy -- -D warnings` 会把它升级成 dead_code error。
按「精确 cfg」修(不用 dwm.rs 那种文件级 allow(dead_code):那会永久掩掉本文件在非
Windows 上的所有死项)。自证方式是真实文件翻转 cfg 的 rustc probe:修前 exit 1
(`constant INVALID_HANDLE_VALUE is never used`)、修后 exit 0 零告警;同一条命令写进
了注释,下一个人可以照着跑。

P2-1:自测条数按实现改成 8 条(红 6 / 绿 2),三处(脚本 docstring、release-build.yml
注释、README §12)一致。
P2-2:文件头那组句柄读数换成 release 产物的实测值(out 0x0→0x1F0 / err 0x0→0x1F4),
并说明数字随实例变化、单测里的 0xE4/0xE8 是示例。
P2-3:README §15 补这段边角语义——但**按实测改写了复审的结论**:两种「既无控制台也不
重定向」的宿主形态(父有句柄不继承 / 父子都无句柄)× 两条命令,child_exit_code 全是 0,
不是 EXIT_INTERNAL;原因是 Rust std 在 Windows 上对取不到的句柄直接返回 Ok(独立探针
实测 SetStdHandle(out)=0x0 / 0x1234 / (HANDLE)-1 三种取值 write/flush 都是 Ok),
所以 runner.rs 那条「写失败 ⇒ EXIT_INTERNAL」的分支不会被触发。
P2-4:NoParentConsole → NoConsole(这个名字同时覆盖「attach 成功但句柄仍不可用」),
诊断文案与注释同步。
2026-11-08 20:12:36 +08:00

312 lines
13 KiB
Python
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.
#!/usr/bin/env python3
"""PE 子系统守卫(M196):**读产物 PE 头的 `Subsystem` 字段**,不许用「代码看起来对」代替。
起因:群友 2026-11-08 实测发布构建「运行时会带一个控制台窗口在底下」。根因是产物 PE 头的
`Subsystem` 仍是 CUI(3)——`grep -rn windows_subsystem crates/` 当时**零命中**。这个修复在
2026-11-01 做过一次(`37d3039`),但打在已退役的 Slint 树(`crates/app`,M118 起被 workspace
`exclude`、不进发布矩阵),所以从未到达 iced 产物。教训不是「忘了属性」,而是**没有任何一步
去看产物本身**:源码层面怎么写都可能被别的树顶替,只有读那 4 个字节是事实。
两向断言(一条也不许静默):
1. **GUI 产物必须是 `Subsystem == 2`(`WINDOWS_GUI`)**:`unlucky-launcher-iced.exe`。
是 3 就意味着双击会多一个控制台宿主窗口——正是本 mission 要修的那个 bug。
2. **CLI 产物必须是 `Subsystem == 3`(`CONSOLE`)**:`ull.exe`。它靠真控制台做 stdout/stderr,
谁把 `windows_subsystem = "windows"` 抄到它头上,`ull --version | …` 就会静默丢输出。
反方向同样要红:只守一个方向时,把属性贴错 crate 会被漏掉。
读法(零第三方依赖,PE32 与 PE32+ 都吃):`MZ` → `e_lfanew`(偏移 `0x3C`) → `PE\\0\\0` →
COFF 头 20 字节 → 可选头起点 → `Subsystem` 在可选头 `+0x44`(两种 magic 一致)。
读数含**文件字节偏移**,报告里贴的是真实位置,不是「我读过了」。
用法(本地与 CI 同一份代码):
python3 packaging/check_pe_subsystem.py --gui target/release/unlucky-launcher-iced.exe \\
--cli target/release/ull.exe
python3 packaging/check_pe_subsystem.py --self-test # 合成 PE 桩,断言该红的红(8 条:红 6 / 绿 2)
`--self-test` 是这条守门的自测证据:它造合成 PE 头(GUI / CUI / 非 MZ / 截断 / 缺文件),
断言「GUI 读成 3 ⇒ 红」「CLI 读成 2 ⇒ 红」「两边都对 ⇒ 绿」——共 **8 条用例(红 6 / 绿 2)**,
另加一条「Subsystem 真落在报出来的那个偏移上」的偏移自证。守门抓不住回归时它自己先红。
"""
from __future__ import annotations
import struct
import sys
import tempfile
from pathlib import Path
#: PE 签名(`e_lfanew` 处那四个字节)。
PE_SIGNATURE = b"PE\0\0"
#: `IMAGE_OPTIONAL_HEADER.Subsystem` 在可选头里的偏移(PE32 与 PE32+ 同一个位置)。
SUBSYSTEM_OFFSET = 0x44
#: 想要的两种取值。
WINDOWS_GUI = 2
CONSOLE = 3
SUBSYSTEM_NAMES = {
0: "UNKNOWN",
1: "NATIVE",
2: "WINDOWS_GUI",
3: "CONSOLE",
5: "OS2_CUI",
7: "POSIX_CUI",
9: "WINDOWS_CE_GUI",
10: "EFI_APPLICATION",
11: "EFI_BOOT_SERVICE_DRIVER",
12: "EFI_RUNTIME_DRIVER",
14: "XBOX",
16: "WINDOWS_BOOT_APPLICATION",
}
#: 可选头 magic → 人话(只用于打印,判据不看它)。
MAGICS = {0x10B: "PE32", 0x20B: "PE32+", 0x107: "ROM"}
class PeError(Exception):
"""读 PE 头失败(不是 PE / 被截断)—— 同样是红的理由,不许当成「跳过」。"""
class PeInfo:
"""一次读数:值 + 它在文件里的位置(位置进报告,是「真读过」的凭据)。"""
__slots__ = ("path", "subsystem", "file_offset", "e_lfanew", "magic", "machine")
def __init__(self, path: Path, subsystem: int, file_offset: int, e_lfanew: int, magic: int, machine: int):
self.path = path
self.subsystem = subsystem
self.file_offset = file_offset
self.e_lfanew = e_lfanew
self.magic = magic
self.machine = machine
@property
def name(self) -> str:
return self.path.name
def describe(self) -> str:
return (
f"[pe] {self.name}: Subsystem={self.subsystem} "
f"({SUBSYSTEM_NAMES.get(self.subsystem, '?')}) @ 文件偏移 0x{self.file_offset:X}"
f"(e_lfanew=0x{self.e_lfanew:X},{MAGICS.get(self.magic, f'0x{self.magic:X}')},"
f"可选头+0x{SUBSYSTEM_OFFSET:X},machine=0x{self.machine:X})"
)
def read_subsystem(path: Path) -> PeInfo:
"""读 PE 可选头的 `Subsystem`。任何一步对不上都抛 [`PeError`],绝不返回「猜的值」。"""
if not path.is_file():
raise PeError(f"{path} 不存在(构建步没跑?还是产物名改了?)")
with path.open("rb") as handle:
dos = handle.read(0x40)
if len(dos) < 0x40:
raise PeError(f"{path} 只有 {len(dos)} 字节,连 DOS 头都不够(文件被截断?)")
if dos[0:2] != b"MZ":
raise PeError(f"{path} 不是 PE:开头是 {dos[0:2]!r},不是 b'MZ'")
e_lfanew = struct.unpack_from("<I", dos, 0x3C)[0]
handle.seek(e_lfanew)
coff = handle.read(4 + 20)
if len(coff) < 24:
raise PeError(f"{path} 的 e_lfanew=0x{e_lfanew:X} 越界(读不到 PE 签名 + COFF 头)")
if coff[0:4] != b"PE\0\0":
raise PeError(f"{path} 在 e_lfanew=0x{e_lfanew:X} 处没有 PE 签名(期望 {PE_SIGNATURE!r}):{coff[0:4]!r}")
machine = struct.unpack_from("<H", coff, 4)[0]
optional_size = struct.unpack_from("<H", coff, 4 + 16)[0]
optional_start = e_lfanew + 24
if optional_size < SUBSYSTEM_OFFSET + 2:
raise PeError(
f"{path} 的 SizeOfOptionalHeader={optional_size},装不下 Subsystem(+0x{SUBSYSTEM_OFFSET:X})"
)
handle.seek(optional_start)
optional = handle.read(SUBSYSTEM_OFFSET + 2)
if len(optional) < SUBSYSTEM_OFFSET + 2:
raise PeError(f"{path} 的可选头被截断:只读到 {len(optional)} 字节")
magic = struct.unpack_from("<H", optional, 0)[0]
if magic not in (0x10B, 0x20B):
raise PeError(f"{path} 的可选头 magic=0x{magic:X} 既不是 PE32(0x10B) 也不是 PE32+(0x20B)")
subsystem = struct.unpack_from("<H", optional, SUBSYSTEM_OFFSET)[0]
return PeInfo(path, subsystem, optional_start + SUBSYSTEM_OFFSET, e_lfanew, magic, machine)
def check(role: str, path: Path, expected: int, errors: list[str]) -> PeInfo | None:
"""按角色断言一个产物的子系统;不匹配就往 `errors` 里写一条带文件注解的 error。"""
try:
info = read_subsystem(path)
except PeError as exc:
errors.append(f"::error::{role} 读数失败:{exc}")
return None
print(info.describe())
if info.subsystem != expected:
want = SUBSYSTEM_NAMES.get(expected, str(expected))
got = SUBSYSTEM_NAMES.get(info.subsystem, str(info.subsystem))
hint = {
WINDOWS_GUI: "双击会多一个控制台宿主窗口(M196 要修的就是这个)",
CONSOLE: "CLI 的 stdout/stderr 会静默丢失(ull 靠真控制台)",
}.get(expected, "")
errors.append(
f"::error file={path}::{role} 的 PE 子系统应为 {expected}({want}),"
f"实际读到 {info.subsystem}({got})@ 偏移 0x{info.file_offset:X};{hint}"
)
return info
def main(argv: list[str]) -> int:
gui: Path | None = None
cli: Path | None = None
args = list(argv)
while args:
arg = args.pop(0)
if arg == "--gui" and args:
gui = Path(args.pop(0))
elif arg == "--cli" and args:
cli = Path(args.pop(0))
elif arg == "--self-test":
return self_test()
elif arg in ("-h", "--help"):
print(__doc__)
return 0
else:
print(f"::error::不认识的参数(或缺少取值):{arg}", file=sys.stderr)
return 2
if gui is None and cli is None:
print("::error::至少要给 --gui <exe> 或 --cli <exe>", file=sys.stderr)
return 2
errors: list[str] = []
if gui is not None:
check("GUI", gui, WINDOWS_GUI, errors)
if cli is not None:
check("CLI", cli, CONSOLE, errors)
for error in errors:
print(error, file=sys.stderr)
if errors:
return 1
print(
f"OK: PE 子系统符合预期(GUI={WINDOWS_GUI} {SUBSYSTEM_NAMES[WINDOWS_GUI]}"
f" / CLI={CONSOLE} {SUBSYSTEM_NAMES[CONSOLE]})"
)
return 0
# ────────────────────────── 自测:合成 PE 桩 ──────────────────────────
def synth_pe(subsystem: int, *, pe32_plus: bool = True, signature: bytes = b"PE\0\0", mz: bytes = b"MZ") -> bytes:
"""造一个只够本脚本读的 PE 头(不执行、不链接,只让 `Subsystem` 落在正确位置)。"""
e_lfanew = 0x80
buf = bytearray(0x200)
buf[0:2] = mz
struct.pack_into("<I", buf, 0x3C, e_lfanew)
buf[e_lfanew : e_lfanew + 4] = signature
struct.pack_into("<H", buf, e_lfanew + 4, 0x8664)
optional_size = 0xF0 if pe32_plus else 0xE0
struct.pack_into("<H", buf, e_lfanew + 4 + 16, optional_size)
optional_start = e_lfanew + 24
struct.pack_into("<H", buf, optional_start, 0x20B if pe32_plus else 0x10B)
struct.pack_into("<H", buf, optional_start + SUBSYSTEM_OFFSET, subsystem)
return bytes(buf)
def self_test() -> int:
failures = 0
with tempfile.TemporaryDirectory(prefix="ull-pe-subsystem-") as tmp:
root = Path(tmp)
def write(name: str, data: bytes) -> Path:
path = root / name
path.write_bytes(data)
return path
cases: list[tuple[str, Path, int, bool]] = [
(
"基线:GUI 产物 Subsystem=2",
write("gui_ok.exe", synth_pe(WINDOWS_GUI)),
WINDOWS_GUI,
False,
),
(
"回归:GUI 产物退回 CUI(3) —— 就是本次 bug 的形状",
write("gui_cui.exe", synth_pe(CONSOLE)),
WINDOWS_GUI,
True,
),
(
"基线:CLI 产物 Subsystem=3",
write("cli_ok.exe", synth_pe(CONSOLE, pe32_plus=False)),
CONSOLE,
False,
),
(
"回归:CLI 产物被贴成 WINDOWS_GUI(2)",
write("cli_gui.exe", synth_pe(WINDOWS_GUI)),
CONSOLE,
True,
),
(
"回归:不是 PE(开头不是 MZ)",
write("not_pe.exe", synth_pe(WINDOWS_GUI, mz=b"ZZ")),
WINDOWS_GUI,
True,
),
(
"回归:PE 头被截断",
write("truncated.exe", synth_pe(WINDOWS_GUI)[: optional_start_of(synth_pe(WINDOWS_GUI)) + 0x10]),
WINDOWS_GUI,
True,
),
(
"回归:签名不是 PE\\0\\0",
write("bad_sig.exe", synth_pe(WINDOWS_GUI, signature=b"PX\0\0")),
WINDOWS_GUI,
True,
),
(
"回归:文件根本不存在(构建步没跑)",
root / "missing.exe",
WINDOWS_GUI,
True,
),
]
for label, path, expected, want_red in cases:
errors: list[str] = []
check("SELFTEST", path, expected, errors)
got_red = bool(errors)
if got_red != want_red:
want = "红" if want_red else "绿"
print(
f"::error::自测失败:{label} 应该{want},实际 {'红' if got_red else '绿'}",
file=sys.stderr,
)
failures += 1
else:
print(f"OK: {label} → {'红' if got_red else '绿'}")
# 位置读数本身也要对:GUI 桩的 Subsystem 必须正好落在报告的那个文件偏移上。
gui = write("offset.exe", synth_pe(WINDOWS_GUI))
info = read_subsystem(gui)
if gui.read_bytes()[info.file_offset : info.file_offset + 2] != struct.pack("<H", WINDOWS_GUI):
print(f"::error::自测失败:报出来的偏移 0x{info.file_offset:X} 上不是 Subsystem", file=sys.stderr)
failures += 1
else:
print(f"OK: 偏移自证 0x{info.file_offset:X} 处的两字节正是 {WINDOWS_GUI}")
if failures:
return 1
print("OK: PE 子系统守卫自测全部通过(该红的红、该绿的不绿)")
return 0
def optional_start_of(pe: bytes) -> int:
"""合成桩里可选头的起点(给「截断」用例算切点用)。"""
return struct.unpack_from("<I", pe, 0x3C)[0] + 24
if __name__ == "__main__":
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(errors="backslashreplace")
sys.exit(main(sys.argv[1:]))