fix(core,app-iced): M308 Linux/macOS 的 .tar.gz 真的能自替换 + 重试按 retryable 分流 + 三处文档纠偏

M307 盘查抓到的一条真缺陷 + 两处交互/文档不一致,本条只修它们,不重做通道:

① Linux/macOS 自更新的真缺陷(选 A:真做)
   `job::self_replaceable` 只认裸 exe,而发布矩阵里那两平台的启动器是 `.tar.gz`(清单记作
   `portable-zip`)⇒ 查得到更新、按钮画得出来、点下去必失败。新增 `core::update::unpack`:
   **归档字节过完长度/摘要双检之后**,从归档里取出启动器本体(tar 头 mode 有可执行位 /
   `.exe` 名字的唯一普通文件),再交给原有的 `install_staged`(原子替换 + 回滚一字未改)。
   零新依赖(flate2/tar 早就是 core 的依赖,`core::game::java` 解 Temurin 用同一套)。
   三条具名错误:`LauncherMissing` / `LauncherNotExecutable`(魔数与 target 不符)/
   `LauncherSizeMismatch`(0 字节 / 超上限 / 与头声明不符);zip 容器**明确拒绝**(没有可靠的
   「哪个条目可执行」判据,不猜)。`target.rs` 的 `PackageFormat` 文档改成与判据逐字一致。

② 重试按钮按 `retryable` 分流
   验签类(`is_retryable == false`)不画「重试」,正文换成新 key `UpdateBannerFailedFinal`
   (「这次更新已被拒绝(重试没用):{reason}」)。文案抽到 `pages/update/failure.rs`
   (`pages/update.rs` 的红线),判据落在**真 Widget 树**上(探针 tag + 递归计数)。

③ 三处文档纠偏 + 同一条谎话的其它落点(都在 scope 内)
   `trust.rs`(正式表已有 `ull-release`,commit 68362e5)、`check_signing_ready.py` 自述、
   `target.rs`;顺带 `packaging/README.md`、`docs/{signing,signing-runbook}.md`、
   `check_test_key_off.py`;新文档 `docs/update-trust-anchor.md` 记「M254 审计的『正式表为空』
   已作废」。

判据:core 新增 `unpack_cases`(Linux tar.gz / macOS tar.gz 走完整条链 + 五种拒绝)、
`job_cases` 的形态表改成两种;app-iced 两条真 Widget 树用例(按钮在 / 不在)+ 一条取词入口用例
+ 一条源码守卫。i18n 加一条 key 并**重出金表**(`Key::ALL` 853 行,新行落在 index 834)。
This commit is contained in:
AskaEth committed 2026-11-13 23:38:17 +08:00
1 parent db24379e35
commit af82658d08
24 files changed
+1201 -58

No files matched your search

+5 -3
View File
@@ -160,7 +160,8 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
- 验收本体:`cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_test
--test update_e2e_tamper_test`(正路 + 幂等 / 篡改摘要 + 篡改包体 + 截断)。CI 里同一个命令挂在
`ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` +
`portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。
`portable-exe`。Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,M308 起客户端**会**拆包装
本体,但那条路没有 e2e——夹具的资产名与形态按 Windows 的发布走,换平台就是另一条 mission)。
**不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。
## 15. 产物 PE 子系统(M196:GUI 必须 `WINDOWS_GUI`=2)
@@ -220,8 +221,9 @@ CUI(3),`grep -rn windows_subsystem crates/` 当时零命中。同一个修复
的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的
**编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装,
**不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。
- **签名就绪度与操作步骤(M255)**:今天 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的** ⇒ 任何正式
二进制都验不过任何清单(fail-closed)。读数一条命令:`python3 packaging/check_signing_ready.py`
- **签名就绪度与操作步骤(M255)**:`PRODUCTION` 表在 `PINNED-KEYS` 标记之间钉着一条 `ull-release`
(commit `68362e5`;M254 审计那句「正式表为空」已作废,见 `docs/update-trust-anchor.md`)。表里一把都
没匹配上时仍然拒装(fail-closed)。读数一条命令:`python3 packaging/check_signing_ready.py`
(三行 + `0/1/2`,未就绪时打印一行修复命令);CI 里同一条读数在 `signing-status.yml` 的 job summary
上,**那是播报不是门禁**(不应设为 required)。要动手做的那 5 步在
[`docs/signing-runbook.md`](docs/signing-runbook.md)(不含任何真实密钥)。
+13 -8
View File
@@ -1,17 +1,21 @@
#!/usr/bin/env python3
"""签名就绪度播报(M255):**只读**、零第三方依赖,三行说清「今天能不能签发一份客户端会认的清单」。
"""签名就绪度播报(M255,**M308 纠偏**):**只读**、零第三方依赖,三行说清「今天能不能签发一份客户端会认的清单」。
为什么要它:M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在
`PINNED-KEYS` 标记之间是**空的**,而空表一律 `KeyringEmpty` 拒装 —— 于是**今天任何正式二进制都验不过
任何清单**(fail-closed 是成立的,但在用户机器上表现为「更新永远失败」)。工具链早就齐了
(`sign_manifest.py` 的 `--generate-key` / `--check-pinned-keyring` / `--sign` / `--verify-pinned`),
缺的是**操作步骤与可见性**。本脚本只负责「现在差什么」,它不生成钥、不写任何文件、不改 `trust.rs`。
为什么要它:M254(CLAIMAUDIT)当时查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在
`PINNED-KEYS` 标记之间是**空的**,而空表一律 `KeyringEmpty` 拒装 —— 那样任何正式二进制都验不过任何
清单(fail-closed 成立,但在用户机器上表现为「更新永远失败」)。**那条读数已经过时**:commit
`68362e5` 起 `PRODUCTION` 里钉着一条 `ull-release`,今天的正式构建认这把钥签的清单。本脚本照旧
只负责「现在差什么」:它不生成钥、不写任何文件、不改 `trust.rs`;① 那一行的判据也从「是不是空的」
变成了「标记之间有几条 PinnedKey」,空表那天它照样会喊未就绪。
工具链早就齐了(`sign_manifest.py` 的 `--generate-key` / `--check-pinned-keyring` / `--sign` /
`--verify-pinned`),缺的从来是**操作步骤与可见性**。
三行就绪度(各一条判据,互不替代):
| 行 | 判据 | 未就绪意味着 |
|---|---|---|
| ① | `PRODUCTION` 表非空:解析 `PINNED-KEYS` 标记之间的 `PinnedKey` 条目,0 条即未就绪 | 客户端一把公钥都没有,任何清单都验不过(今天就是这个状态) |
| ① | `PRODUCTION` 表非空:解析 `PINNED-KEYS` 标记之间的 `PinnedKey` 条目,0 条即未就绪 | 客户端一把公钥都没有,任何清单都验不过(**不是**今天的状态:表里已有 `ull-release`;哪天有人把标记之间清空,这一行会立刻喊未就绪) |
| ② | 环境里有签名钥:`ULL_MANIFEST_SIGNING_KEY`(或 `--key <文件>`)能解出 32 字节种子,**且**它的公钥在 ① 的表里 | 没钥签不了;钥与信任表不是同一把 ⇒ 发布时 `--check-pinned-keyring` 当场红 |
| ③ | 最近一次发布的 `manifest.json` / `.sig` 痕迹 | **不参与就绪判定**。取不到证据就写「未知」——「本地没留档」不等于「上次没签」 |
@@ -78,7 +82,8 @@ def table_state(path: Path) -> tuple[bool, str, list[tuple[str, str]]]:
if keys:
names = "、".join(f"{kid}({pk[:16]}…)" for kid, pk in keys)
return True, f"就绪 —— 标记之间 {len(keys)} 条:{names}({path}:{line_no} 起)", keys
# 空表就是今天的状态。顺手查一个真实易错点:有人把条目贴到标记**外面**(`];` 之后)。
# 标记之间 0 条 = 未就绪(M254 那天就是这个状态;今天表里有 `ull-release`,这条分支只会在
# 有人清空标记之间的内容时亮)。顺手查一个真实易错点:有人把条目贴到标记**外面**(`];` 之后)。
# ⚠ 本仓另有一把 `TEST_ONLY` 测试钥**本来就在标记之外**(那是它的正确位置),所以这里只**点名**,
# 不替人下结论 —— 上一版写成「贴错位置,等于没钉」,在真实 trust.rs 上是误报(实测抓出来的)。
strays = [kid for kid, _ in KID_RE.findall(text[:begin]) + KID_RE.findall(text[end:])]
+2 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""守 `update-test-key`:这把**公开**的测试公钥不许进发布构建(M190 r3)。
背景:`crates/core` 的信任表(`trust::PRODUCTION`)正式构建里是空的(fail-closed,等用户录钥),
背景:`crates/core` 的信任表(`trust::PRODUCTION`)在正式构建里只有**生产钥**(`ull-release`,commit
`68362e5` 起;M254 审计那句「正式表为空」已作废,见 `docs/update-trust-anchor.md`),
测试公钥 `trust::TEST_ONLY` 只在三档「仅测试」构建里并入:`cfg(test)`、测试通道那条 feature、
`update-test-key`。第三档是给 `crates/presenter` 的 `[dev-dependencies]` 用的——presenter 的更新链路
用例要构造「本构建认得」的签名清单,而它编译 core 时既没有 `cfg(test)`,也不该打开
@@ -64,9 +64,12 @@ published 一列,Termux 行标注 experimental)——改一边必须同步
`windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**(前者靠 `dumpbin -dependents` 的静态 CRT
门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟);本机能做且已做的是脚本层
取证(§7.5:宿主判定注入、可执行文件头识别、把真 exe 的 PE 机器码改成 ARM64 当 stub 走完全流程)。
8. **Linux 上自更新只"能查不能装"**(M184 发布 Linux x64 之后才有意义):清单现在会带 `linux-x64` 的
`portable-zip` 产物,而 `ull update apply` 只做便携单文件 `.exe` 的自替换
(`crates/core/src/update/job.rs::ensure_self_replaceable`)——Linux 上它会带具名原因**拒绝**
("发布里的 linux-x64 产物是 portable-zip 形态,不能原地替换启动器 … 压缩包请解压后再用"),
不会半装坏。Linux 的更新方式仍是「下载新归档覆盖旧副本」。真要做 Linux 自更新(换目录里的文件再重启)
是另一件事,未规划。
8. **Linux/macOS 自更新:M308 起能装**(M184 发布 Linux x64 之后这条才真正有意义)。清单里
`linux-x64` / `mac-*-*` 的产物是 `portable-zip`(真实容器是 `.tar.gz`),M308 之前在
`job::ensure_self_replaceable` 处带具名原因**拒绝**;现在 `core::update::unpack` 会在归档字节
过完长度/摘要双检之后把里面的启动器本体拆出来,再走同一套原子替换 + 回滚
(判据表见 `crates/core/src/update/job.rs::self_replaceable` 与 `unpack.rs` 的模块文档)。
三条仍然**不做**的事:①不在检查阶段过滤产物形态(清单挂错形态时是「点下去带原因失败」,
不是「装了个坏的」);②不装安装器形态(msi/dmg/deb/rpm 交给系统包管理器);
③不认 zip 容器(tar 头里有 `mode`,能客观判「哪个条目是本体」;zip 没有等价约定,宁可拒绝也不猜)。
随包文本(LICENSE/NOTICE/README)不随更新落地:更新只换启动器本体,首次安装的归档里那份仍然有效。
+11 -4
View File
@@ -1,16 +1,23 @@
# 签名就绪度操作手册(M255)
> **⚠ M308 纠偏(2026-11-13)**:本文写于「信任表是空的」那些日子。commit `68362e5` 起
> `trust::PRODUCTION` 在 `PINNED-KEYS` 标记之间钉着一条 `ull-release`,所以 M254 那条
> 「任何正式二进制都验不过任何清单」的读数**已作废**(见 `docs/update-trust-anchor.md`)。
> 下文凡出现「表空 ⇒ 今天必然红」「信任表是空的」,请读成「**那天**必然红」;步骤本身没变,
> 只是第 0 节不再是当下的状态,而 §3 的「一次性录入」已经做过一次了。
本文件是**人类照做**的手册:从「信任表是空的」走到「发出去的 Release 用客户端判据能验过」。
背景/威胁模型/轮换纪律在 [`signing.md`](signing.md);本文件只讲**步骤、命令、坑**。
> ⚠ 本文件**不含**任何真实私钥、种子或公钥。所有示例都是 `<尖括号占位符>`。
> `--generate-key` 的输出是**敏感物**:只在你要贴进 Secret 的那一次运行里存在,别存文件、别贴 issue。
## 0. 现状读一遍(2026-11 的 M254 审计结论)
## 0. 现状读一遍(2026-11 的 M254 审计结论;**已被 M308 取代**)
`crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的**。空表 =
`KeyringEmpty` 一律拒装 ⇒ **今天任何正式二进制都验不过任何清单**(fail-closed 成立,但用户端表现为
「更新永远失败」)。已存在的部分(都能用):
M254 审计当时读到 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是
**空的**(空表 = `KeyringEmpty` 一律拒装 ⇒ 用户端表现为「更新永远失败」)。**那条读数已作废**:
commit `68362e5` 钉入 `ull-release` 之后,正式构建认得这把钥签的清单。下表「今天」一列也随之为
历史(现在 `--check-pinned-keyring` 绿,其余两行取决于环境里有没有配 Secret)。已存在的部分(都能用):
| 环节 | 在哪 | 今天的状态 |
|---|---|---|
+5 -1
View File
@@ -50,10 +50,14 @@ manifest.json(字节)──签名──> manifest.json.sig
### 空表 = fail-closed
正式表当前**是空的**:私钥只能来自仓库 Secret,任何代码仓库都不该自己生成并落盘私钥。
空表的效果是「任何正式构建都拒装任何清单」,同时 `--check-pinned-keyring` 让发布流水线当场红。
**这是有意的**:宁可不出 Release,也不出未签名的 Release。
⚠ 正式表**现在不是空的**:commit `68362e5` 钉入了 `ull-release`(M308 纠偏;M254 审计那句
「正式表为空」已作废,见 `docs/update-trust-anchor.md`)。上面那条「空表 ⇒ 拒装」仍然成立——
它说的是「表里一把都没匹配上」时的行为,不是当下的表内容。私钥只能来自仓库 Secret,
任何代码仓库都不该自己生成并落盘私钥(这条没变)。
## 3. 一次性录入(只做一次)
```bash
+5 -2
View File
@@ -45,8 +45,11 @@ MANIFEST_NAME = "manifest.json"
CHANNELS = ("stable", "beta", "alpha", "nightly")
# 文件名里的平台段 -> 清单 target 的系统段(`windows_x64` -> `win-x64`)。
PLATFORM_TARGET = {"windows": "win", "macos": "mac", "linux": "linux"}
# 产物扩展名 -> 清单 format。归档(tar.gz)在 DTO 里只有「解压即用」这一档 `portable-zip`:
# 名字带 zip,语义是「非安装器、可原地替换」(PackageFormat::needs_installer() == false)。
# 产物扩展名 -> 清单 format。归档(tar.gz)在 DTO 里只有 `portable-zip` 这一档:名字带 zip,
# 语义是「非安装器、可原地替换」(`PackageFormat::needs_installer() == false`)。
# M308 起这条语义是**真的**:`crates/core/src/update/unpack.rs` 会把归档里那个可执行的本体拆出来
# 再顶替原文件(此前客户端只认裸 exe,于是 Linux/macOS 的更新点下去必失败)。所以归档里的启动器
# 必须带可执行位(`make_artifacts.py` 的 `0o755`),随包文本保持 `0o644`——那是客户端挑本体的判据之一。
EXT_FORMAT = {".exe": "portable-exe", ".tar.gz": "portable-zip"}
GOLDEN = Path(__file__).resolve().parent / "tests" / "manifest.golden.json"
# DTO 侧的取值表镜像(crates/core/src/update/target.rs::parse_os / Arch::parse / PackageFormat::parse)。