From 9163054d46e560edbeff559a36f40faca45b18ef Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 09:32:51 +0800 Subject: [PATCH 1/6] =?UTF-8?q?refactor(presenter):=20=E5=B8=A6=20header?= =?UTF-8?q?=20=E7=9A=84=E8=AF=B7=E6=B1=82=E9=87=8D=E8=AF=95=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E6=94=B9=E5=BC=95=20core=20=E7=9A=84=E5=8D=95?= =?UTF-8?q?=E4=B8=80=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 台账 §10 第 4 条:http.rs 的 const ATTEMPTS: u32 = 3 是「数字 3 的第二处副本」。 改成引用 unlucky_core::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE(download.retry_limit 的 schema 默认值同源),core 侧一个字未改。 这条带 header 的路径(只有 CurseForge 用)拿不到宿主设置,所以只吃出厂默认——这一点写进了 doc 注释,免得下一个人以为它会跟随用户的 download.retry_limit。 --- crates/presenter/src/host/addon/http.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/presenter/src/host/addon/http.rs b/crates/presenter/src/host/addon/http.rs index f73368e..1655028 100644 --- a/crates/presenter/src/host/addon/http.rs +++ b/crates/presenter/src/host/addon/http.rs @@ -20,6 +20,7 @@ use std::time::Duration; use unlucky_core::download::blocking::block_on; +use unlucky_core::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE; use unlucky_core::download::{DownloadRequest, ResourceKind}; use super::super::env::HostEnv; @@ -29,7 +30,12 @@ pub(super) const USER_AGENT: &str = "Unlucky-Launcher/0.1 (+https://github.com/AskaEth/Unlucky-Launcher)"; /// 最多重试次数(含首次)。 -const ATTEMPTS: u32 = 3; +/// +/// **不在这里写数字**:取值就是下载引擎的出厂默认 +/// (`core::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE`,同一份 schema 默认值 +/// `download.retry_limit`)。这条带 header 的路径拿不到宿主设置,所以只吃出厂默认—— +/// 数字本身只有一处来源,改工厂默认时这里跟着走。 +const ATTEMPTS: u32 = DEFAULT_MAX_ATTEMPTS_PER_SOURCE; /// 取一段文本(JSON 原文)。 pub(super) fn get(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result { From 06fcbb8a51e54f441501a11602c62f597c4d7ecd Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 09:32:54 +0800 Subject: [PATCH 2/6] =?UTF-8?q?docs(packaging):=20=E4=BF=AE=E6=8E=89?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=20=C2=A714=20=E6=92=9E=E5=8F=B7=EF=BC=88M190?= =?UTF-8?q?=20=E8=AE=A9=E4=BD=8D=E5=88=B0=20=C2=A716=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 台账 §10 第 5 条:packaging/README.md 里 §14 被 M169(更新链路测试通道)与 M190(产物署名与 清单签名)各用了一次。 判据(谁后插进来谁让位):§14 与 §15 都被仓库里的引用钉着——packaging/check_test_source_off.py 引 §14(M169),.github/workflows/release-build.yml 与 crates/app-iced/src/platform/win32/console.rs 引 §15(M196);这两处引用都在本 mission 的 scope 外。所以 M169 保持 §14、M196 保持 §15, M190 那一节整段后移到文末编 §16,内容一字未改,只改编号与位置(新增一段「编号来历」说明为什么 它让位)。全文 §11/§12/§13/§14/§15/§16 各一次,无重复。 --- packaging/README.md | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packaging/README.md b/packaging/README.md index 47c2ec6..1360eec 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -149,20 +149,6 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) `portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。 **不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。 -## 14. 产物署名与清单签名(M190) - -这一节只放「去哪看」的三句话,手册本体在 **`packaging/docs/signing.md`**: - -- **PE 版本资源**(Windows「属性 → 详细信息」里的发布者/产品/版权/四段式版本):口径唯一来源 - `packaging/pe_version.rs`,被 `crates/cli/build.rs` 与 `crates/app-iced/build.rs` 各 `include!` 一次; - 判据是**读产物字节**的 `packaging/check_pe_version.py`(CI:`release-build.yml` 的 Windows 槽位)。 -- **清单签名**:`manifest.json.sig` 与 `manifest.json` 并列上传,是分离的 Ed25519 签名文件(签的是清单 - 的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的 - **编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装, - **不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。 -- **不得混淆**:项目自有签名解决的是「更新器怎么判断这份清单是不是我们发的」,**不解决 SmartScreen** - ——用户双击 exe 时看到的「未知发布者」要等 Authenticode 证书,那是另一条线。 - ## 15. 产物 PE 子系统(M196:GUI 必须 `WINDOWS_GUI`=2) 群友 2026-11-08 实测发布构建「运行时会带一个控制台窗口在底下」。根因:产物 PE 头的 `Subsystem` 仍是 @@ -203,3 +189,22 @@ CUI(3),`grep -rn windows_subsystem crates/` 当时零命中。同一个修复 `instances list` / `--help` 全 0(`logs list` 是 1,但重定向跑同一条也是 1 ⇒ 用户错误,不是写失败)。 想让这种情形有专属退出码/文案,得在 CLI 侧显式判一次能力(`platform::win32::console` 的 `output_visible()` 就是现成判据),那在 `crates/cli/**`、不在 M196 的 scope 内——这里只如实记录形态。 + +## 16. 产物署名与清单签名(M190) + +**编号来历(M203)**:本节原先也编作 §14,与 §14「更新链路的测试通道(M169)」撞号。撞号按「谁后插进来谁让位」 +处理:M169 的 §14 与 M196 的 §15 都被仓库里的引用钉着(`packaging/check_test_source_off.py` 引 §14; +`.github/workflows/release-build.yml` 与 `crates/app-iced/src/platform/win32/console.rs` 引 §15), +所以本节后移编 §16 并放到文末——**内容一字未改,只改了编号和位置**。 + +这一节只放「去哪看」的三句话,手册本体在 **`packaging/docs/signing.md`**: + +- **PE 版本资源**(Windows「属性 → 详细信息」里的发布者/产品/版权/四段式版本):口径唯一来源 + `packaging/pe_version.rs`,被 `crates/cli/build.rs` 与 `crates/app-iced/build.rs` 各 `include!` 一次; + 判据是**读产物字节**的 `packaging/check_pe_version.py`(CI:`release-build.yml` 的 Windows 槽位)。 +- **清单签名**:`manifest.json.sig` 与 `manifest.json` 并列上传,是分离的 Ed25519 签名文件(签的是清单 + 的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的 + **编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装, + **不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。 +- **不得混淆**:项目自有签名解决的是「更新器怎么判断这份清单是不是我们发的」,**不解决 SmartScreen** + ——用户双击 exe 时看到的「未知发布者」要等 Authenticode 证书,那是另一条线。 From 133f14ba0beb730d3fcb23f54f918636566c795e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 09:32:58 +0800 Subject: [PATCH 3/6] =?UTF-8?q?test(presenter):=20=E5=9D=8F=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E7=94=A8=E4=BE=8B=E6=8C=89=E9=94=99=E8=AF=AF=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=8C=BA=E5=88=86=E3=80=8CJSON=20=E5=9D=8F=E3=80=8D?= =?UTF-8?q?=E4=B8=8E=E3=80=8C=E7=AD=BE=E5=90=8D=E8=A2=AB=E6=8B=92=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 台账 §10 第 7 条 / M199 复审 P3-2:原来只断言「结果是 Failed」,而它喂的每一份清单都签对了 ——验签一旦 fail-open 或签名错误被误分类,这条用例照样绿。 现在两组各钉自己的分类: - 内容坏(5 个变体,签名都对):kind 必须是 invalid_manifest / invalid_version; - 字节好、只有签名对不上(放行就会报「有更新 0.9.0」):kind 必须是 signature_invalid 且不可重试。 变异证明(只改实现侧,跑完已还原): - A core::update::signature::verify_manifest fail-open → ② 分支红,实际 Available(0.9.0); - B core::update::manifest::from_json 把解析失败误分类成 SignatureInvalid → ① 分支红, 「not json:坏内容分类是 signature_invalid」。 两个变异各只打红对应分支,说明两类坏输入确实各走各的分类。 文件仍在 400 行红线内(394 行)。 --- crates/presenter/src/host/update_tests.rs | 41 +++++++++++++++++------ 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/presenter/src/host/update_tests.rs b/crates/presenter/src/host/update_tests.rs index 58057d5..077c144 100644 --- a/crates/presenter/src/host/update_tests.rs +++ b/crates/presenter/src/host/update_tests.rs @@ -6,7 +6,7 @@ //! | 用例 | 守什么 | //! |---|---| //! | [`version_matrix_decides_who_gets_prompted`] | 版本更高才提示;同版本/更低一律不提示;`v0.2.3` 这类真实 tag 形态 | -//! | [`a_broken_manifest_degrades_to_failure_not_up_to_date`] | 清单坏 JSON → 「检查失败」,**不是**「已是最新」 | +//! | [`a_broken_manifest_degrades_to_failure_not_up_to_date`] | 坏 JSON → `invalid_*`、签名被拒 → `signature_*`;两种都是「检查失败」,**不是**「已是最新」 | //! | [`a_higher_version_without_our_platform_is_not_up_to_date`] | 有新版但没本平台产物 → 单独的变体 | //! | [`sha256_mismatch_refuses_the_apply_and_leaves_no_temp_file`] | 摘要不符拒绝应用、暂存文件不残留、目标没被换掉 | //! | [`a_successful_apply_leaves_the_new_file_and_a_backup`] | 换上去的是新字节、旧字节留在备份里、回报是「就位」 | @@ -191,11 +191,16 @@ fn version_matrix_decides_who_gets_prompted() { // ------------------------------------------------------------- 清单契约降级 -/// 坏 JSON / 缺字段 ⇒ **检查失败**,绝不是「已是最新」。 +/// 坏 JSON / 缺字段 / 签名被拒 ⇒ **检查失败**,绝不是「已是最新」;而且两种坏法**分类不同**。 /// -/// 两个变体的文案与后果完全不同:说成「已是最新」会让用户以为网络通、版本新,实际一个字节都没取到。 +/// M199 复审 P3-2:旧版只断言「结果是 Failed」,而它喂的清单都**签对了**——验签一旦 fail-open, +/// 坏签名清单会一路走到解析,坏 JSON 照样解析失败,断言全绿。现在内容坏必须落 `invalid_*`, +/// 签名坏必须落 `signature_*` 且不可重试。 +/// 抓得住:验签被跳过/放行(② 会拿到 `Available 0.9.0`)、签名与解析互相误分类、失败被折叠成「已是最新」; +/// 抓不住:签名算法细节(信封字段、`manifest_sha256` 冗余校验、钥表)——那在 core 的 `signature_cases.rs`。 #[test] fn a_broken_manifest_degrades_to_failure_not_up_to_date() { + // ① 签名是对的(`write_manifest` 成对落盘),内容坏:分类只能是「清单结构 / 版本号不合法」。 for (label, manifest) in [ ("not json", "{ this is not json".to_string()), ( @@ -221,16 +226,32 @@ fn a_broken_manifest_degrades_to_failure_not_up_to_date() { let tmp = TempDir::new("badmanifest"); write_manifest(&tmp, &manifest); let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable)); + let Some(failure) = outcome.failure() else { + panic!("{label}:坏清单必须降级成「检查失败」,实际 {outcome:?}"); + }; + let kind = failure.kind.as_str(); assert!( - matches!(outcome, UpdateOutcome::Failed(_)), - "{label}:坏清单必须降级成「检查失败」,实际 {outcome:?}" - ); - let failure = outcome.failure().expect("失败带原因"); - assert!( - !failure.message.trim().is_empty(), - "{label}:失败要有人话原因" + matches!(kind, "invalid_manifest" | "invalid_version"), + "{label}:坏内容分类是 {kind}" ); + assert!(!failure.message.trim().is_empty(), "{label}:失败没原因"); } + // ② 清单字节是好的(放行就会报「有更新 0.9.0」),只有签名对不上:必须落 `signature_invalid`。 + let tmp = TempDir::new("badsignature"); + tmp.write( + "dist/stable.json", + manifest_for("0.9.0", b"new", "").as_bytes(), + ); + tmp.write( + "dist/stable.json.sig", + signature_envelope("another").as_bytes(), + ); + let outcome = outcome_of(job(&tmp).check(&version("0.2.2"), Channel::Stable)); + let Some(failure) = outcome.failure() else { + panic!("签名验不过必须拒装(放行就会报「有更新 0.9.0」),实际 {outcome:?}"); + }; + assert_eq!(failure.kind, "signature_invalid", "签名被拒的分类"); + assert!(!failure.retryable, "签名问题不可重试"); } /// 清单里**一条发布都没有**(`{"channel":"stable"}` / 空 `releases`)⇒ 已是最新,不是失败。 From ba96a4fc94a43475797933b611edf4a16dc3b64b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 09:49:36 +0800 Subject: [PATCH 4/6] =?UTF-8?q?docs(presenter):=20=E6=9B=B4=E6=AD=A3=20ATT?= =?UTF-8?q?EMPTS=20=E6=B3=A8=E9=87=8A=E9=87=8C=E3=80=8C=E6=8B=BF=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=E5=AE=BF=E4=B8=BB=E8=AE=BE=E7=BD=AE=E3=80=8D=E7=9A=84?= =?UTF-8?q?=E5=81=87=E8=AF=9D=EF=BC=88=E5=A4=8D=E5=AE=A1=20P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原文把「没读设置」写成了物理限制。事实:get_with_headers 手里就有 env,第一句 engine(env) 已经走过 apply_tuning(host/settings/download.rs → core/download/shared.rs),用户当前值就在 engine.config().max_attempts_per_source。这条路径之所以恒为出厂默认,只是因为它没读那个字段。 只改注释,行为不变(仍是编译期 const)。同时把不对称写清:另一条无 header 的路径走引擎, 本来就跟随 download.retry_limit(源冷却中时引擎给 1 次,那是恢复探测)。 --- crates/presenter/src/host/addon/http.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/presenter/src/host/addon/http.rs b/crates/presenter/src/host/addon/http.rs index 1655028..9e508cb 100644 --- a/crates/presenter/src/host/addon/http.rs +++ b/crates/presenter/src/host/addon/http.rs @@ -32,9 +32,14 @@ pub(super) const USER_AGENT: &str = /// 最多重试次数(含首次)。 /// /// **不在这里写数字**:取值就是下载引擎的出厂默认 -/// (`core::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE`,同一份 schema 默认值 -/// `download.retry_limit`)。这条带 header 的路径拿不到宿主设置,所以只吃出厂默认—— -/// 数字本身只有一处来源,改工厂默认时这里跟着走。 +/// (`core::download::config::DEFAULT_MAX_ATTEMPTS_PER_SOURCE`,`download.retry_limit` 的 schema +/// 默认值同源),改工厂默认时这里跟着走。 +/// +/// 边界说清:这条路径**没读**宿主设置(次数是编译期常量),所以固定吃出厂默认——**不是拿不到**。 +/// `env` 就在手上(`get_with_headers` 第一句 `engine(env)` 已经灌过调优),用户当前值就在 +/// `engine.config().max_attempts_per_source`;要让它跟随 `download.retry_limit`,读那个字段即可, +/// 不需要新拉一条参数链。另一条无 header 的路径([`get_via_engine`])走引擎,本来就跟随设置 +/// (源冷却中时引擎只给 1 次,那是恢复探测,见 `DownloadEngine::attempts_for`)。 const ATTEMPTS: u32 = DEFAULT_MAX_ATTEMPTS_PER_SOURCE; /// 取一段文本(JSON 原文)。 From 3b5f95375d71d474cf0c91defc2d8c62b81f0206 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 09:49:41 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs(presenter):=20=E3=80=8C=E6=8A=93?= =?UTF-8?q?=E4=B8=8D=E4=BD=8F=E3=80=8D=E9=82=A3=E6=AE=B5=E5=88=AB=E5=86=8D?= =?UTF-8?q?=E7=82=B9=E5=90=8D=20signature=5Fcases.rs=20=E7=AE=A1=20manifes?= =?UTF-8?q?t=5Fsha256=EF=BC=88=E5=A4=8D=E5=AE=A1=20P2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 信封字段与钥表确实在 core/src/update/tests/signature_cases.rs;但 manifest_sha256 那条冗余校验 (signature.rs:161-171,要求「钥已钉住 + 方程验过 + 声明 sha 不同」)全仓没有任何用例覆盖, 现有篡改用例都先死在方程上。原文把这一项也推给那个文件,等于把责任推给了不存在的守卫。 改成如实写「全仓无用例,已立 finding」。只动 doc,用例逻辑与断言未改(394 → 395 行)。 --- crates/presenter/src/host/update_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/presenter/src/host/update_tests.rs b/crates/presenter/src/host/update_tests.rs index 077c144..50379af 100644 --- a/crates/presenter/src/host/update_tests.rs +++ b/crates/presenter/src/host/update_tests.rs @@ -197,7 +197,8 @@ fn version_matrix_decides_who_gets_prompted() { /// 坏签名清单会一路走到解析,坏 JSON 照样解析失败,断言全绿。现在内容坏必须落 `invalid_*`, /// 签名坏必须落 `signature_*` 且不可重试。 /// 抓得住:验签被跳过/放行(② 会拿到 `Available 0.9.0`)、签名与解析互相误分类、失败被折叠成「已是最新」; -/// 抓不住:签名算法细节(信封字段、`manifest_sha256` 冗余校验、钥表)——那在 core 的 `signature_cases.rs`。 +/// 抓不住:签名信封字段与钥表(在 core 的 `signature_cases.rs`),以及 `manifest_sha256` 那条冗余校验 +/// (需「钥已钉住 + 方程验过 + 声明 sha 不同」,**全仓无用例**,已立 finding)。 #[test] fn a_broken_manifest_degrades_to_failure_not_up_to_date() { // ① 签名是对的(`write_manifest` 成对落盘),内容坏:分类只能是「清单结构 / 版本号不合法」。 From 8e37bb9cf9abf11b230226d17a8b4ff56671ae60 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 09:49:44 +0800 Subject: [PATCH 6/6] =?UTF-8?q?docs(packaging):=20=C2=A712=20=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=B8=85=E5=8D=95=E9=87=8C=E6=9C=AC=E6=96=87=E7=9A=84?= =?UTF-8?q?=E8=8A=82=E5=8F=B7=E8=8C=83=E5=9B=B4=E8=A1=A5=E5=88=B0=20=C2=A7?= =?UTF-8?q?11=E2=80=93=C2=A716?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审观察 1:那张表还写「索引 + §11 / §12 / §13」,而本文现在也有 §14/§15/§16(含本次新编的 §16)。是改动前就存在的陈旧,顺手补全。 --- packaging/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/README.md b/packaging/README.md index 1360eec..f9060c7 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -59,7 +59,7 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一) | 文件 | 作用 | 行数上限 | |------|------|---------| -| `packaging/README.md` | 本文:索引 + §11 / §12 / §13(契约入口;§1–§10 见 `docs/`) | ≤400 | +| `packaging/README.md` | 本文:索引 + §11–§16(契约入口;§1–§10 见 `docs/`) | ≤400 | | `packaging/docs/artifacts-and-version.md` | §1 产物矩阵 + §2 版本来源 | ≤400 | | `packaging/docs/build-and-ci.md` | §3 怎么构建(本地 / CI) | ≤400 | | `packaging/docs/runtime-deps.md` | §4 每平台运行时依赖 + §5 软渲染兜底 | ≤400 |