fix(m196): GUI 产物 PE Subsystem → WINDOWS_GUI(2),并用 AttachConsole 保住 CLI 输出

群友 2026-11-08 实测发布构建「运行时会带一个控制台窗口在底下」。根因:产物 PE 头
Subsystem 仍是 CUI(3)。同一个修复 2026-11-01 做过一次(37d3039),但只改了已被
workspace exclude 的 Slint 树(crates/app),从未到达 iced 产物。

- crates/app-iced/src/main.rs:加 #![cfg_attr(windows, windows_subsystem = "windows")],
  并写清为什么放 bin 层而不是 build.rs(PE 子系统是每个 bin 目标的属性)。
- crates/app-iced/src/platform/win32/console.rs(新):AttachConsole(ATTACH_PARENT_PROCESS)
  的落点 + 判据 should_attach(纯函数,可断言)。GUI 子系统进程不继承父控制台,实测
  从 cmd 启动时三个 std 句柄全是 0;附着后 GetStdHandle 立刻给出控制台句柄(0x0→0x208),
  所以不改 std 句柄、不碰用户的重定向(ATTACH_PARENT_PROCESS 只会附到已存在的控制台,
  永不新建窗口)。
- packaging/check_pe_subsystem.py(新):读产物 PE 头 Subsystem 字段(GUI 必须 2、
  CLI ull 必须 3),非 2 即红;--self-test 注入 5 条合成 PE 回归。
- 接进 release-build.yml(发布侧两槽位)与 ci.yml build job(PR 阶段)。
- guards.yml:unsafe 落点期望集合变成两处(console.rs + transparency.rs),集合相等判据其余不变;
  平台层所有「全仓唯一 unsafe 落点」措辞同步改正。
This commit is contained in:
tower-worker committed 2026-11-08 19:33:56 +08:00
1 parent 30b61e2684
commit 14f9318e09
15 files changed
+693 -36

No files matched your search

+310
View File
@@ -0,0 +1,310 @@
#!/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 桩,断言该红的红
`--self-test` 是这条守门的自测证据:它造合成 PE 头(GUI / CUI / 非 MZ / 截断 / 缺文件),
断言「GUI 读成 3 ⇒ 红」「CLI 读成 2 ⇒ 红」「两边都对 ⇒ 绿」。守门抓不住回归时它自己先红。
"""
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:]))