diff --git a/crates/presenter/src/host/addon/http.rs b/crates/presenter/src/host/addon/http.rs index f73368e..9e508cb 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,17 @@ 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`,`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 原文)。 pub(super) fn get(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result { diff --git a/crates/presenter/src/host/update_tests.rs b/crates/presenter/src/host/update_tests.rs index 58057d5..50379af 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,17 @@ fn version_matrix_decides_who_gets_prompted() { // ------------------------------------------------------------- 清单契约降级 -/// 坏 JSON / 缺字段 ⇒ **检查失败**,绝不是「已是最新」。 +/// 坏 JSON / 缺字段 / 签名被拒 ⇒ **检查失败**,绝不是「已是最新」;而且两种坏法**分类不同**。 /// -/// 两个变体的文案与后果完全不同:说成「已是最新」会让用户以为网络通、版本新,实际一个字节都没取到。 +/// M199 复审 P3-2:旧版只断言「结果是 Failed」,而它喂的清单都**签对了**——验签一旦 fail-open, +/// 坏签名清单会一路走到解析,坏 JSON 照样解析失败,断言全绿。现在内容坏必须落 `invalid_*`, +/// 签名坏必须落 `signature_*` 且不可重试。 +/// 抓得住:验签被跳过/放行(② 会拿到 `Available 0.9.0`)、签名与解析互相误分类、失败被折叠成「已是最新」; +/// 抓不住:签名信封字段与钥表(在 core 的 `signature_cases.rs`),以及 `manifest_sha256` 那条冗余校验 +/// (需「钥已钉住 + 方程验过 + 声明 sha 不同」,**全仓无用例**,已立 finding)。 #[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 +227,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`)⇒ 已是最新,不是失败。 diff --git a/packaging/README.md b/packaging/README.md index 47c2ec6..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 | @@ -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 证书,那是另一条线。