diff --git a/crates/app-iced/src/pages/update.rs b/crates/app-iced/src/pages/update.rs index 92e02f6..ea8534a 100644 --- a/crates/app-iced/src/pages/update.rs +++ b/crates/app-iced/src/pages/update.rs @@ -103,6 +103,11 @@ pub struct Update { } mod busy; +// M308:失败提示条的正文与按钮(按 `retryable` 分流——验签类不画「重试」)。 +mod failure; + +use failure::failed_actions; +pub use failure::failed_body; impl Update { /// 有没有可装的候选(有的话设置页给「下载并安装」)。 @@ -300,16 +305,10 @@ pub fn banner<'a>( )], Role::PrimaryContainer, ), - Banner::Failed { reason, .. } => ( + Banner::Failed { reason, retryable } => ( catalog.text(Key::UpdateBannerTitleFailed).to_string(), - reason.to_string(), - vec![buttons::text( - ui, - Button::new( - catalog.text(Key::UpdateBannerRetry), - Message::Update(UpdateAction::CheckNow), - ), - )], + failed_body(catalog, reason, retryable), + failed_actions(ui, catalog, retryable), Role::ErrorContainer, ), }; diff --git a/crates/app-iced/src/pages/update/failure.rs b/crates/app-iced/src/pages/update/failure.rs new file mode 100644 index 0000000..1b66661 --- /dev/null +++ b/crates/app-iced/src/pages/update/failure.rs @@ -0,0 +1,54 @@ +//! 失败提示条的**文案与按钮**(M308:按 `retryable` 分流)。 +//! +//! 判据来自 core:`UpdateErrorKind::is_retryable` 只有字节层(摘要 / 长度)、网络、IO 算可重试; +//! 签名五兄弟(漏签 / 签名坏 / 表里没这把钥 / 签名对不上)与归档三兄弟(没有唯一本体 / 本体不是 +//! 本平台的可执行文件 / 本体大小不合规)都**不可重试**——成因在发布侧,重放一千次还是同一个坏包。 +//! +//! | `retryable` | 正文 | 按钮 | +//! |---|---|---| +//! | `true` | 原因本身(「隔一会儿再试可能就过了」) | 一颗「重试」 | +//! | `false` | 「这次更新已被拒绝(重试没用):原因」 | **没有**(空表) | +//! +//! 为什么拆到本文件:`pages/update.rs` 贴着 400 行硬线(AGENTS §3-1),而「失败了该说什么、 +//! 还能不能点」是独立的一块问题(与 `busy.rs` 同一取舍)。拆开之后正文那一半还能被单测直接喂—— +//! `text` 控件在 iced 0.14 里是 stateless 的(`tag() == Tag::stateless()`),真控件树里读不到字符串, +//! 所以「哪句话」只能在取词入口断、「按钮在不在」才在真控件树上断(`tests.rs`)。 + +use iced::Element; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::buttons::{self, Button}; +use crate::pages::message::UpdateAction; +use crate::pages::Message; +use crate::theme::Ui; + +/// 失败提示条的正文。 +#[must_use] +pub fn failed_body(catalog: Catalog, reason: &str, retryable: bool) -> String { + if retryable { + return reason.to_string(); + } + catalog.render(Key::UpdateBannerFailedFinal, &[("reason", reason)]) +} + +/// 失败提示条的按钮:**只有可重试才画「重试」**。 +/// +/// 不可重试时返回空表:那颗键根本不在控件树里(M307 抓到的第二处不一致——此前不论 +/// `retryable` 都画一颗「重试」,而 `error.rs` 早就把验签类标成不可重试了)。 +pub(super) fn failed_actions<'a>( + ui: &'a Ui, + catalog: Catalog, + retryable: bool, +) -> Vec> { + if !retryable { + return Vec::new(); + } + vec![buttons::text( + ui, + Button::new( + catalog.text(Key::UpdateBannerRetry), + Message::Update(UpdateAction::CheckNow), + ), + )] +} diff --git a/crates/app-iced/src/pages/update/tests.rs b/crates/app-iced/src/pages/update/tests.rs index 3f3b590..a654f89 100644 --- a/crates/app-iced/src/pages/update/tests.rs +++ b/crates/app-iced/src/pages/update/tests.rs @@ -2,8 +2,15 @@ use super::*; +use iced::advanced::widget::tree::Tag; +use iced::advanced::widget::Tree; + +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; use unlucky_i18n::Lang; +use crate::theme::Families; + /// 提示条的正文**同时**给体积与说明摘要(mission:提示条含版本号、体积、说明摘要)。 #[test] fn the_available_banner_carries_both_size_and_notes() { @@ -54,3 +61,128 @@ fn the_banner_priority_matches_the_presenter() { update.ready = Some("0.2.3".to_string()); assert!(matches!(update.banner(), Some(Banner::Ready { .. }))); } + +// ---------------------------------------------------------------- M308:重试按 retryable 分流 +// +// 判据分两条腿(真控件树读不到字符串:iced 0.14 的 `text` 是 stateless,`tag()` 恒为 +// `Tag::stateless()`): +// +// | 要断的事 | 用什么 | 抓不住什么 | +// |---|---|---| +// | 不可重试时**没有**「重试」按钮 / 可重试时**有** | 真控件树里按钮节点的条数(探针取 tag,与 `pages/instances/tests/cold_boot.rs` 同一手法) | 是哪一枚(按条数判) | +// | 那句「已拒绝、重试没用」 | `failed_body`(**唯一**取词入口,中英各一条) | 它被 layout 丢掉(那是像素的事) | +// | 提示条真的用了那个入口 | 源码守卫(断言 `banner()` 里那行调用还在) | 参数传对没传对(前一条管文案,这一条只管接线) | +// +// 变异:把 `failed_actions` 改成无条件返回那枚按钮 ⇒ 第一条用例红; +// 把 `banner()` 的正文改回 `reason.to_string()` ⇒ 第三条用例红(前两条抓不住这一种,写在明处)。 + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui() -> Ui { + Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) +} + +/// 一次**不可重试/可重试**的失败结论(`silent = false`:用户主动查过,提示条要出来)。 +fn failed(retryable: bool) -> Update { + Update { + status: UpdateStatus::Failed { + reason: "清单签名对不上清单字节".to_string(), + retryable, + }, + ..Update::default() + } +} + +/// 探针:任意一个 `buttons::*` 元素在树里的标签(`interaction::tracked` 那层包裹是同一个类型, +/// 所以「可选按钮」与「禁用的按钮」都算同一个 tag)。 +fn button_tag(ui: &Ui) -> Tag { + let probe = buttons::filled( + ui, + Button::new("probe", Message::Update(UpdateAction::CheckNow)), + ); + Tree::new(probe.as_widget()).tag +} + +fn count_tag(tree: &Tree, tag: Tag) -> usize { + usize::from(tree.tag == tag) + + tree + .children + .iter() + .map(|child| count_tag(child, tag)) + .sum::() +} + +/// 这条结论画出来的提示条里有几枚按钮(**真控件树**)。 +fn button_count(ui: &Ui, update: &Update, catalog: Catalog) -> usize { + let element = banner(ui, update, catalog).expect("这条用例的状态总要画一条提示条"); + count_tag(&Tree::new(element.as_widget()), button_tag(ui)) +} + +/// 验签类失败(`retryable = false`):**那枚「重试」按钮不在树里**,正文换成「已拒绝、重试没用」。 +/// +/// 这是 M307 抓到的第二处不一致:`error.rs` 早把签名五兄弟标成不可重试,界面照旧画一颗重试。 +#[test] +fn a_refused_update_draws_no_retry_button_and_says_why() { + let ui = ui(); + let catalog = Catalog::of(Lang::ZhCn); + let update = failed(false); + + assert_eq!( + button_count(&ui, &update, catalog), + 0, + "不可重试的失败里不许出现「重试」那枚按钮(重放一千次还是同一个坏包)" + ); + + let zh = failed_body(catalog, "清单签名对不上清单字节", false); + assert!( + zh.contains("这次更新已被拒绝"), + "正文要说清「已被拒绝」:{zh}" + ); + assert!(zh.contains("重试没用"), "正文还要说清「重试没用」:{zh}"); + assert!( + zh.contains("清单签名对不上清单字节"), + "原因要原样带出来(用户要知道是哪一步拒的):{zh}" + ); + let en = failed_body(Lang::En.catalog(), "the signature does not match", false); + assert!( + en.contains("retrying will not help"), + "英文同一条判据:{en}" + ); +} + +/// 可重试的失败(网络 / 摘要不符 / IO):「重试」按钮**在**树里,且正文就是原因(不换句子)。 +#[test] +fn a_retryable_failure_keeps_the_retry_button() { + let ui = ui(); + let catalog = Catalog::of(Lang::ZhCn); + let update = failed(true); + + assert_eq!( + button_count(&ui, &update, catalog), + 1, + "可重试的失败必须留一条重试的路(M161:失败不许把按钮永久钉死)" + ); + assert_eq!( + failed_body(catalog, "取更新清单失败:连接被重置", true), + "取更新清单失败:连接被重置", + "可重试的正文就是原因本身,别套「已拒绝」那句" + ); +} + +/// 提示条的正文**走** `failed_body`(源码守卫)。 +/// +/// 为什么要这一条:`text` 控件没有状态,真控件树里读不到那句话;`failed_body` 自己的用例只能 +/// 证明「函数返回值对」,证明不了「提示条用了它」。变异(把 `banner()` 的正文改回 +/// `reason.to_string()`)只会让这一条红——这就是它的全部价值,说在明处。 +#[test] +fn the_banner_routes_its_body_through_failed_body() { + let source = include_str!("../update.rs"); + assert!( + source.contains("failed_body(catalog, reason, retryable)"), + "提示条的失败正文必须经取词入口 failed_body(...):这句话不见了就等于「重试没用」又被吞掉" + ); +} diff --git a/crates/core/src/update/error.rs b/crates/core/src/update/error.rs index 5826669..5a21f7e 100644 --- a/crates/core/src/update/error.rs +++ b/crates/core/src/update/error.rs @@ -48,6 +48,16 @@ pub enum UpdateErrorKind { SignatureUnknownKey, /// 签名对不上清单字节(清单被改过,或签名是给另一份清单的)。 SignatureInvalid, + /// 归档形态的产物里没有**唯一**的启动器本体(0 个候选,或不止一个)。 + /// + /// 发布侧问题(打包脚本漏了可执行位、或一个归档里塞了两个启动器):换源、重试都不变好。 + LauncherMissing, + /// 归档里取出的本体不是 `target` 那个平台的可执行文件(魔数对不上)。 + /// + /// 也是发布侧问题:`win-x64` 挂了个 ELF、或 `linux-x64` 挂了个 PE,都是清单写错。 + LauncherNotExecutable, + /// 归档里取出的本体大小不合规:0 字节、超过上限、或与归档头声明的不符。 + LauncherSizeMismatch, } impl UpdateErrorKind { @@ -69,6 +79,9 @@ impl UpdateErrorKind { Self::KeyringEmpty => "keyring_empty", Self::SignatureUnknownKey => "signature_unknown_key", Self::SignatureInvalid => "signature_invalid", + Self::LauncherMissing => "launcher_missing", + Self::LauncherNotExecutable => "launcher_not_executable", + Self::LauncherSizeMismatch => "launcher_size_mismatch", } } @@ -79,6 +92,9 @@ impl UpdateErrorKind { /// /// 签名五兄弟**一律不可重试**:签名不对不是抖动,重试一千次还是不对。把它们标成可重试 /// 会让壳层反复弹「网络问题,稍后重试」,掩盖真正的原因(发布侧漏签 / 钥没钉 / 清单被改)。 + /// + /// 归档三兄弟(`LauncherMissing` / `LauncherNotExecutable` / `LauncherSizeMismatch`)同理: + /// 它们说的是「发布侧打错了包」,重下一次拿到的还是同一个坏归档。 #[must_use] pub const fn is_retryable(self) -> bool { matches!( diff --git a/crates/core/src/update/job.rs b/crates/core/src/update/job.rs index 2a0f750..51322ce 100644 --- a/crates/core/src/update/job.rs +++ b/crates/core/src/update/job.rs @@ -45,6 +45,7 @@ use super::manifest::Artifact; use super::source::DirSource; use super::source::UpdateSource; use super::target::{PackageFormat, Target}; +use super::unpack; use super::verify::stage; use super::version::Version; @@ -185,8 +186,12 @@ impl Job { // 先按包形态挡一次,**挡在取包之前**:既不浪费下载,也不会在目标路径上留下任何东西。 ensure_self_replaceable(&candidate.artifact)?; let staged = stage(&*self.source, &candidate.artifact, &self.staging)?; + // M308:归档形态(Linux/macOS 的 `.tar.gz` 在清单里就是 `portable-zip`)在这里拆出 + // 启动器本体。**位置是有意的**:上面那一行已经把归档的字节按清单的长度/摘要双检过, + // 所以「拆」只发生在可信字节上(见 `unpack` 模块文档)。 + let launcher = unpack::launcher_binary(staged, &candidate.artifact)?; let version = candidate.release.version.to_string(); - match install_staged(&staged, &self.dest, ReplacePolicy::default()) { + match install_staged(&launcher, &self.dest, ReplacePolicy::default()) { Ok(receipt) => Ok(Applied::Installed { receipt, version }), Err(failure) => Err(failure.error), } @@ -195,15 +200,28 @@ impl Job { /// 这个包形态能不能**原地**变成启动器本体。 /// -/// 只有「便携单文件 exe」([`PackageFormat::PortableExe`])可以: +/// 两类可以: +/// +/// | 形态 | 怎么变成本体 | +/// |---|---| +/// | [`PackageFormat::PortableExe`] | 它**就是**本体:直接顶替原文件 | +/// | [`PackageFormat::PortableZip`] | 是归档(zip / tar.gz):校验通过后由 [`super::unpack`] 拆出里面的本体 | +/// +/// 其余都不行: /// /// | 形态 | 为什么不行 | /// |---|---| /// | `msi` / `dmg` / `deb` / `rpm` | D50 明说「不做 MSI」——那是系统包管理器的活(`needs_installer()`) | -/// | `portable-zip` | 是压缩包,不是启动器本体:rename 到 exe 路径只会得到一个跑不起来的文件 | -/// | `appimage` | Linux 的可执行镜像,Windows/macOS 上没有意义;且本模块只做「同目录改名换 exe」 | +/// | `appimage` | 本模块不做 AppImage 的安装语义(`.AppImage` 落到哪、desktop 集成谁做);发布矩阵目前也不发它 | +/// +/// 「`portable-zip` 也算」这条是 M308 修的:发布矩阵里 Linux/macOS 的启动器就是 `.tar.gz` +/// (`packaging/make_artifacts.py` 的 SLOTS),只认裸 exe ⇒ 那两平台**查得到更新、按钮画得出来、 +/// 点下去必失败**。门禁的判据在 [`ensure_self_replaceable`],拆包的实现在 [`super::unpack`]。 pub(super) fn self_replaceable(format: PackageFormat) -> bool { - matches!(format, PackageFormat::PortableExe) + matches!( + format, + PackageFormat::PortableExe | PackageFormat::PortableZip + ) } /// 包形态不合规就拒绝(错误分类是 `InvalidManifest`:清单挂错了东西,换源/重试都没用)。 @@ -212,8 +230,8 @@ fn ensure_self_replaceable(artifact: &Artifact) -> Result<(), UpdateError> { return Ok(()); } Err(UpdateError::invalid_manifest(format!( - "发布里的 {} 产物是 {} 形态,不能原地替换启动器:本启动器只做便携单文件 exe 的自替换\ - (安装器形态请用系统包管理器;压缩包请解压后再用)", + "发布里的 {} 产物是 {} 形态,不能变成启动器本体:本启动器只做便携 exe / 便携归档的自替换\ + (安装器形态请用系统包管理器)", artifact.target.as_str(), artifact.format.as_str() ))) diff --git a/crates/core/src/update/mod.rs b/crates/core/src/update/mod.rs index 18117c2..ff86565 100644 --- a/crates/core/src/update/mod.rs +++ b/crates/core/src/update/mod.rs @@ -19,6 +19,7 @@ //! │ Available(candidate) //! ├─ UpdateChecker::prepare(candidate, staging) 取包 → 长度/摘要双检 //! │ │ StagedPackage(校验通过,失败已自动清理) +//! │ ├─ unpack::launcher_binary(...) 归档才拆:取出启动器本体(M308) //! │ └─ install::install_staged(...) 改名旧版 → 搬入新版(失败回滚) //! └─ UpToDate / MissingArtifact //! ``` @@ -33,7 +34,8 @@ //! signature 清单签名信封 + 验签 trust 钉进二进制的公钥表(keyring) //! ed25519 Ed25519 严格验签(ed25519-dalek::verify_strict 的薄封装) //! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点) -//! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口) +//! unpack 归档形态的产物 → 启动器本体 error 分类与文案 +//! job 检查/应用的一条龙(GUI 与 CLI 共用同一入口) //! ``` //! //! # 跨卷(M171) @@ -77,6 +79,7 @@ mod signature; pub mod source; pub mod target; mod trust; +mod unpack; pub mod verify; pub mod version; diff --git a/crates/core/src/update/target.rs b/crates/core/src/update/target.rs index 292d31e..06d3bf5 100644 --- a/crates/core/src/update/target.rs +++ b/crates/core/src/update/target.rs @@ -132,8 +132,12 @@ impl fmt::Display for Target { /// 安装包形态。 /// /// 只区分「安装器装出来的」与「解压即用的」两大类,具体怎么装由打包层决定: -/// 安装器装的实例(msi/dmg/deb/rpm)本模块**不主张**原地覆盖(见 `install` 模块文档), -/// 便携形态(portable-zip / appimage / portable-exe)才走自替换。 +/// 安装器形态(msi/dmg/deb/rpm)本模块**不主张**原地覆盖(见 `install` 模块文档); +/// 便携形态才走自替换,而**哪两种便携形态真的能变成启动器本体,判据只有一处**: +/// [`super::job::self_replaceable`](`portable-exe` 直接顶替、`portable-zip` 由 +/// [`super::unpack`] 拆出本体)。改了那边的 `matches!` 就必须改这段文档,反之亦然—— +/// M307 抓到的正是这两处各说一套(`portable-zip` 被文档列为可自替换、判据却只认裸 exe, +/// 于是 Linux/macOS 查得到更新、点下去必失败)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum PackageFormat { /// Windows 安装器。 @@ -146,7 +150,11 @@ pub enum PackageFormat { Deb, /// RPM 包。 Rpm, - /// 便携 zip(解压即用)。 + /// 便携归档(解压即用):清单里 Linux/macOS 的 `.tar.gz` 用的就是它。 + /// + /// 名字里的 `zip` 是历史包袱(DTO 早先只打算发 zip);`parse` 同时认 `zip` 与 + /// `portable-zip`,而**字节的容器由魔数判**(tar.gz / tar / zip 三种都认,见 + /// [`super::unpack`])——扩展名与清单词面都不能当判据。 PortableZip, /// 便携单文件 exe。 PortableExe, diff --git a/crates/core/src/update/tests/job_cases.rs b/crates/core/src/update/tests/job_cases.rs index 68dc5ac..1911b55 100644 --- a/crates/core/src/update/tests/job_cases.rs +++ b/crates/core/src/update/tests/job_cases.rs @@ -49,24 +49,27 @@ fn fixture(format: &str, version: &str) -> (TempDir, Job) { (dir, job) } -/// 只有便携单文件 exe 能原地变成启动器本体。 +/// 能变成启动器本体的形态**只有两种**:裸 exe 与便携归档。 /// -/// 抓得住:把闸门写成 `!needs_installer()`(那样 `portable-zip` 会漏过去,zip 会被 rename 成 -/// exe,得到一个跑不起来的启动器)。 +/// 抓得住两边的错法:把闸门放宽成「全格式都算」(`msi`/`deb` 会被改名成启动器)、 +/// 或者收回成「只认裸 exe」(Linux/macOS 的 `.tar.gz` 重新变成「查得到、点必失败」——M307 那条)。 #[test] -fn only_portable_exe_is_self_replaceable() { +fn only_portable_exe_and_archives_are_self_replaceable() { assert!(self_replaceable(PackageFormat::PortableExe)); + assert!( + self_replaceable(PackageFormat::PortableZip), + "Linux/macOS 的 `.tar.gz` 在清单里就是这一档(M308)" + ); for format in [ PackageFormat::Msi, PackageFormat::Dmg, PackageFormat::Deb, PackageFormat::Rpm, PackageFormat::AppImage, - PackageFormat::PortableZip, ] { assert!( !self_replaceable(format), - "{} 不该被当成可原地自替换的形态", + "{} 不该被当成能变成启动器本体的形态", format.as_str() ); } @@ -75,7 +78,7 @@ fn only_portable_exe_is_self_replaceable() { /// 安装器形态被拒绝,且**目标文件一个字节都没动、暂存目录没建**(闸门在取包之前)。 #[test] fn an_installer_artifact_is_refused_before_any_download() { - for format in ["msi", "portable-zip"] { + for format in ["msi", "dmg", "deb", "rpm", "appimage"] { let (dir, job) = fixture(format, "9.9.9"); let error = job .apply(&Version::parse("0.2.2").expect("版本"), Channel::Stable) diff --git a/crates/core/src/update/tests/mod.rs b/crates/core/src/update/tests/mod.rs index 5ec5af5..5246557 100644 --- a/crates/core/src/update/tests/mod.rs +++ b/crates/core/src/update/tests/mod.rs @@ -10,9 +10,12 @@ //! 跨卷(暂存在 A 卷、目标在 B 卷)安装成功且字节完整、中途失败不留半截文件、 //! 同卷不多搬一个字节、旧备份只留最近一份(崩溃现场的唯一旧副本例外)、 //! 跨卷守卫「探不到第二块卷」时不许静默变绿(裁决用例见 `install_xdev_cases`); -//! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、 +//! * 一条龙(`job`):只有便携 exe 与便携归档能变成启动器本体(安装器形态在取包之前就被拒)、 //! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来, //! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给); +//! * 归档拆本体(`unpack`):Linux `.tar.gz` / macOS zip 走完整条链(旧版进备份),以及三种具名 +//! 失败——归档里没有唯一本体、本体不是本平台的可执行文件、本体大小不合规(另加「不是归档」)。 +//! 条目名带 `..` 的不算候选;暂存目录在失败后不留残渣; //! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝; //! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。 //! @@ -32,5 +35,6 @@ mod source_cases; mod support; mod support_sign; mod target_cases; +mod unpack_cases; mod verify_cases; mod version_cases; diff --git a/crates/core/src/update/tests/unpack_cases.rs b/crates/core/src/update/tests/unpack_cases.rs new file mode 100644 index 0000000..ed80286 --- /dev/null +++ b/crates/core/src/update/tests/unpack_cases.rs @@ -0,0 +1,296 @@ +//! 归档形态的产物 → 启动器本体(M308):`unpack` 那一跳的用例。 +//! +//! 为什么这一层必须单独测:M307 盘查抓到的真缺陷是「Linux/macOS 查得到更新、按钮画得出来、 +//! 点下去必失败」——发布矩阵里那两平台的启动器是 `.tar.gz`(清单记作 `portable-zip`),而判据只认 +//! 裸 exe。所以这里既要有**整条链能跑通**的正例(归档 → 双检 → 拆本体 → 原子替换),也要有 +//! 三条**各自具名**的失败(找不到本体 / 本体不是本平台的可执行文件 / 本体大小不合规)—— +//! 三种错因归到一个 `InvalidManifest` 就等于没归因。 +//! +//! 归档全部在内存里现造(`flate2` / `tar` / `zip` 都是 core 的常规依赖,零新面); +//! 「路径穿越」那条不是靠断言错误分类证明的,而是**结构上不可能**:本模块只往自己挑的路径写 +//! 一个文件,条目名从不参与落盘(见 `unpack` 模块文档),用例顺带证明畸形条目连候选都不算。 + +use flate2::write::GzEncoder; +use flate2::Compression; + +use crate::platform::os_info::OsFamily; +use crate::update::job::self_replaceable; +use crate::update::{ + Applied, Arch, Channel, DirSource, Job, PackageFormat, Target, UpdateErrorKind, UpdateSource, + Version, +}; + +use super::support::{artifact, manifest, release, TempDir}; + +/// 三个平台的可执行文件魔数(与 `packaging/make_artifacts.py::image_arch` 同一套)。 +const ELF: &[u8] = b"\x7fELF\x02\x01\x01\x00 new launcher payload"; +const MACHO: &[u8] = b"\xcf\xfa\xed\xfe new launcher payload"; +const PE: &[u8] = b"MZ\x90\x00 new launcher payload"; + +const STAGE: &str = "unlucky-launcher_9.9.9_linux_x64"; + +/// 一条归档条目:`(名字, 字节, unix 权限位)`。 +type Entry<'a> = (&'a str, &'a [u8], u32); + +/// 造一个 tar.gz(mtime 归零,同字节 ⇒ 同摘要,与 `make_artifacts.py` 同一条口径)。 +fn tar_gz(entries: &[Entry<'_>]) -> Vec { + let mut gz = GzEncoder::new(Vec::new(), Compression::best()); + { + let mut builder = tar::Builder::new(&mut gz); + for (name, bytes, mode) in entries { + let mut header = tar::Header::new_gnu(); + header.set_mode(*mode); + header.set_mtime(0); + header.set_size(bytes.len() as u64); + builder + .append_data(&mut header, name, *bytes) + .expect("写 tar 条目"); + } + builder.finish().expect("收尾 tar"); + } + gz.finish().expect("收尾 gzip") +} + +/// 造一个**畸形条目名**的 tar.gz:`tar::Builder::append_data` 会拒绝 `..` 路径, +/// 所以这里先把名字写成合法的、再直接把原始字节盖到 header 的 name 字段上(病毒式的包就是这么来的)。 +fn tar_gz_with_raw_name(name: &str, bytes: &[u8], mode: u32) -> Vec { + let mut gz = GzEncoder::new(Vec::new(), Compression::best()); + { + let mut builder = tar::Builder::new(&mut gz); + let mut header = tar::Header::new_gnu(); + header.set_mode(mode); + header.set_mtime(0); + header.set_size(bytes.len() as u64); + header.set_path("placeholder").expect("占位名"); + header.as_mut_bytes()[..name.len()].copy_from_slice(name.as_bytes()); + header.set_cksum(); + builder.append(&header, bytes).expect("追加条目"); + builder.finish().expect("收尾 tar"); + } + gz.finish().expect("收尾 gzip") +} + +/// 一个 zip(只为证明「zip 被明确拒绝」,见下一条用例)。 +fn zip_of() -> Vec { + let mut buffer = std::io::Cursor::new(Vec::new()); + { + use std::io::Write as _; + let mut writer = zip::ZipWriter::new(&mut buffer); + let options = zip::write::SimpleFileOptions::default().unix_permissions(0o755); + writer + .start_file("unlucky-launcher", options) + .expect("写 zip 条目"); + writer.write_all(ELF).expect("写 zip 内容"); + writer.finish().expect("收尾 zip"); + } + buffer.into_inner() +} + +/// 造一个本地源 + 一个替换目标(`payload` 是**归档字节**,清单的 size/sha256 按它算)。 +fn fixture(target: Target, format: &str, payload: &[u8]) -> (TempDir, Job) { + let dir = TempDir::new("unpack"); + dir.write_signed( + "stable.json", + &manifest( + "stable", + &[release( + "9.9.9", + "", + &[artifact(target.as_str(), format, "asset.bin", payload)], + )], + ), + ); + dir.write("asset.bin", payload); + let dest = dir.join("bin/unlucky-launcher"); + std::fs::create_dir_all(dest.parent().expect("父目录")).expect("建目标目录"); + std::fs::write(&dest, b"old-bytes").expect("旧版本"); + let job = Job::new( + Box::new(DirSource::new(dir.path())) as Box, + target, + dir.join("staging"), + dest, + ); + (dir, job) +} + +fn linux() -> Target { + Target::new(OsFamily::Linux, Arch::X64) +} + +fn mac() -> Target { + Target::new(OsFamily::MacOs, Arch::Arm64) +} + +/// 跑一次 `apply` 并要它失败,返回错误分类(顺带断言目标**一个字节都没动**)。 +/// +/// `before_download` 区分两种拒绝时机:闸门在取包之前(安装器形态)时暂存目录**都不该被建**; +/// 归档拆包失败时归档已经下过、暂存目录已建,但**不许留任何残渣**(归档与拆出来的临时文件 +/// 都由 `StagedPackage::Drop` 清掉)。 +fn refused(job: &Job, expected: UpdateErrorKind, before_download: bool) { + let error = job + .apply(&Version::parse("0.2.2").expect("版本"), Channel::Stable) + .expect_err("不该装上"); + assert_eq!( + error.kind, expected, + "分类不对(归因是这条 mission 的一半):{error:?}" + ); + assert_eq!( + std::fs::read(job.dest()).expect("读目标"), + b"old-bytes", + "拒绝之后目标文件不许被动过" + ); + if before_download { + assert!( + !job.staging().exists(), + "闸门在取包之前,暂存目录都不该被建出来" + ); + } else { + assert_eq!( + super::support::names_in(job.staging()), + Vec::::new(), + "暂存目录里不许留下半成品" + ); + } +} + +/// **Linux 那条真缺陷的正面**:`.tar.gz` 走完整条链 —— 归档字节双检过、本体拆出来、旧版进备份。 +#[test] +fn a_linux_tar_gz_runs_the_whole_chain() { + let archive = tar_gz(&[ + (&format!("{STAGE}/unlucky-launcher"), ELF, 0o755), + (&format!("{STAGE}/LICENSE"), b"license text", 0o644), + ]); + let (_dir, job) = fixture(linux(), "portable-zip", &archive); + let applied = job + .apply(&Version::parse("0.2.2").expect("版本"), Channel::Stable) + .expect("应当装上"); + let Applied::Installed { receipt, version } = applied else { + panic!("应当装上"); + }; + assert_eq!(version, "9.9.9"); + assert_eq!( + std::fs::read(job.dest()).expect("读目标"), + ELF, + "目标上必须是归档里那个**本体**,不是整个 .tar.gz" + ); + assert_eq!( + receipt.bytes, + u64::try_from(ELF.len()).expect("长度"), + "收据里的字节数说的是装上的本体" + ); + let backup = receipt.backup.expect("旧版本应留备份"); + assert_eq!(std::fs::read(backup).expect("读备份"), b"old-bytes"); +} + +/// macOS(`mac-arm64`)同样是 `.tar.gz`:本体是 Mach-O,随包文本 0644 不算候选。 +#[test] +fn a_macos_tar_gz_runs_the_whole_chain() { + let archive = tar_gz(&[ + ("Ull/unlucky-launcher", MACHO, 0o755), + ("Ull/NOTICE", b"notice text", 0o644), + ]); + let (_dir, job) = fixture(mac(), "portable-zip", &archive); + let applied = job + .apply(&Version::parse("0.2.2").expect("版本"), Channel::Stable) + .expect("应当装上"); + assert!(matches!(applied, Applied::Installed { .. })); + assert_eq!(std::fs::read(job.dest()).expect("读目标"), MACHO); +} + +/// 归档里没有本体(只有随包文本)⇒ `LauncherMissing`。抓得住「只认可执行位」这条判据被放松。 +#[test] +fn an_archive_without_a_launcher_is_refused() { + let archive = tar_gz(&[ + (&format!("{STAGE}/LICENSE"), b"license text", 0o644), + (&format!("{STAGE}/README.md"), b"readme", 0o644), + ]); + let (_dir, job) = fixture(linux(), "portable-zip", &archive); + refused(&job, UpdateErrorKind::LauncherMissing, false); +} + +/// 归档里有两个像本体的条目 ⇒ `LauncherMissing`(**不猜**装哪个)。 +#[test] +fn an_archive_with_two_launchers_is_refused() { + let archive = tar_gz(&[ + (&format!("{STAGE}/unlucky-launcher"), ELF, 0o755), + (&format!("{STAGE}/unlucky-launcher.exe"), PE, 0o755), + ]); + let (_dir, job) = fixture(linux(), "portable-zip", &archive); + refused(&job, UpdateErrorKind::LauncherMissing, false); +} + +/// 条目名带 `..` 的不算候选(本模块从不按条目名落盘,这条是「连候选都不给」)。 +/// +/// 判据写成 `LauncherMissing` 而不是「装上了」:唯一那个可执行条目被挡掉 ⇒ 归档里等于没有本体。 +#[test] +fn a_dotdot_entry_is_never_a_candidate() { + let archive = tar_gz_with_raw_name("../../unlucky-launcher", ELF, 0o755); + let (_dir, job) = fixture(linux(), "portable-zip", &archive); + refused(&job, UpdateErrorKind::LauncherMissing, false); +} + +/// 本体是别的平台的产物(`win-x64` 的条目里躺着 ELF)⇒ `LauncherNotExecutable`。 +/// +/// 这是「清单写错」那一类:摘要与大小都对(我们就是对这份字节算的),但字节不是 `MZ`。 +#[test] +fn a_launcher_for_another_platform_is_refused() { + let archive = tar_gz(&[(&format!("{STAGE}/unlucky-launcher"), ELF, 0o755)]); + let target = Target::new(OsFamily::Windows, Arch::X64); + let (_dir, job) = fixture(target, "portable-zip", &archive); + refused(&job, UpdateErrorKind::LauncherNotExecutable, false); +} + +/// 本体 0 字节(打了可执行位但内容是空的)⇒ `LauncherSizeMismatch`。 +#[test] +fn a_zero_byte_launcher_is_refused() { + let archive = tar_gz(&[ + (&format!("{STAGE}/unlucky-launcher"), b"", 0o755), + (&format!("{STAGE}/LICENSE"), b"license text", 0o644), + ]); + let (_dir, job) = fixture(linux(), "portable-zip", &archive); + refused(&job, UpdateErrorKind::LauncherSizeMismatch, false); +} + +/// 清单说归档、字节却不是任何已知容器 ⇒ `InvalidManifest`(清单与产物对不上)。 +#[test] +fn bytes_that_are_not_an_archive_are_refused() { + let (_dir, job) = fixture(linux(), "portable-zip", b"just some bytes, no magic"); + refused(&job, UpdateErrorKind::InvalidManifest, false); +} + +/// zip 在 `portable-zip` 这一档下被**明确拒绝**(不是猜哪个条目是本体)。 +/// +/// 这条用例守的是一个决定:发布矩阵只出 `.tar.gz`,zip 没有可靠的「哪个条目可执行」判据, +/// 所以宁可拒绝并点名原因,也不装错文件。 +#[test] +fn a_zip_is_refused_with_a_reason() { + let (dir, job) = fixture(linux(), "portable-zip", &zip_of()); + refused(&job, UpdateErrorKind::InvalidManifest, false); + drop(dir); +} + +/// 判据表:便携 exe 与便携归档能自替换,安装器形态与 AppImage 不能。 +/// +/// 抓得住:把 `self_replaceable` 放宽成「全格式都算」(安装器形态会漏过去,`msi`/`deb` 会被 +/// 改名成启动器)、或者收回成「只认裸 exe」(Linux/macOS 的归档重新变成「点了必失败」)。 +#[test] +fn the_self_replaceable_table_is_two_formats() { + assert!(self_replaceable(PackageFormat::PortableExe)); + assert!( + self_replaceable(PackageFormat::PortableZip), + "Linux/macOS 的 `.tar.gz` 就是这一档(M308 修的正是这里)" + ); + for format in [ + PackageFormat::Msi, + PackageFormat::Dmg, + PackageFormat::Deb, + PackageFormat::Rpm, + PackageFormat::AppImage, + ] { + assert!( + !self_replaceable(format), + "{} 不该被当成能变成启动器本体的形态", + format.as_str() + ); + } +} diff --git a/crates/core/src/update/trust.rs b/crates/core/src/update/trust.rs index a4992cc..38cc89d 100644 --- a/crates/core/src/update/trust.rs +++ b/crates/core/src/update/trust.rs @@ -21,12 +21,17 @@ //! 以及 changelog 里点名新钥的 `key_id` 与指纹。**不允许**在没有旧钥签名的情况下替换这张表: //! 那等于让「能改仓库的人」变成「能改信任根的人」。 //! -//! # 正式构建的表当前是**空的**(fail-closed) +//! # 正式构建的表里有**一把**钥(`ull-release`,commit `68362e5` 起) //! -//! 私钥只能来自仓库 Secret,本轮没有(也不该由代码仓库产生)私钥,所以正式表里一个条目都没有: -//! 任何正式构建拿到清单都会以 [`super::error::UpdateErrorKind::SignatureMissing`] 之类的分类拒绝更新, -//! 而 `packaging/sign_manifest.py --check-pinned-keyring` 会让发布流水线在「Secret 没配 / 公钥没钉」 -//! 时**当场红**(宁可不出 Release,也不出未签名的 Release)。 +//! ⚠ 早先这里写的是「正式表当前是空的(fail-closed)」——**那句话已作废**(M254 审计留下的旧口径, +//! M308 纠偏;作废声明另见 `docs/update-trust-anchor.md`)。现在 `PRODUCTION` 里钉着一条 +//! `PinnedKey { key_id: "ull-release", … }`:正式构建拿到**由这把私钥签过**的清单会照常验签通过。 +//! +//! 仍然 fail-closed 的是「表里一把都没匹配上」的那些情况——签名缺失 / 坏掉 / 钥不认得 / 签名对不上 +//! ([`super::error::UpdateErrorKind`] 的签名五兄弟),它们一律拒装,绝不「没钥就放行」。 +//! 而「Secret 没配 / 公钥没钉」这类**发布侧**的漏项由 +//! `packaging/sign_manifest.py --check-pinned-keyring` 在流水线上**当场红** +//! (宁可不出 Release,也不出未签名的 Release)。 //! //! 录一把真钥的两步(细节见 `packaging/docs/signing.md`): //! diff --git a/crates/core/src/update/unpack.rs b/crates/core/src/update/unpack.rs new file mode 100644 index 0000000..91b8bc8 --- /dev/null +++ b/crates/core/src/update/unpack.rs @@ -0,0 +1,320 @@ +//! 归档形态的产物 → **启动器本体**(M308)。 +//! +//! # 为什么有这一步 +//! +//! `packaging/make_artifacts.py` 的发布矩阵里,Linux/macOS 的启动器是 `.tar.gz`(里面一个 +//! `unlucky-launcher` + LICENSE/NOTICE/README),清单把它记成 [`PackageFormat::PortableZip`]。 +//! M307 盘查抓到的真缺陷就是这里:`job::self_replaceable` 只认裸 exe,于是 Linux/macOS +//! **查得到更新、按钮画得出来、点下去必失败**。修法不是「把归档改名成 exe」(那只会得到一个 +//! 跑不起来的文件),而是**在校验通过之后、替换之前**把本体取出来。 +//! +//! # 顺序:先验签,再拆包(不许反过来) +//! +//! 清单里的 `size` / `sha256` 说的是**归档字节**,所以 [`super::verify::stage`] 仍然对归档做 +//! 长度+摘要双检(先验签、再解析的同一口径见 [`super`] 模块文档)。本模块**只**在 `StagedPackage` +//! 已经存在之后被调用:拆出来的字节由「归档过了双检」背书,因此这里不另算一份摘要—— +//! 多算一份只会让「信谁」变成两个答案。同理,本模块对外只交另一个 [`StagedPackage`], +//! 替换那一跳([`super::install::install_staged`])一个字母都不用改。 +//! +//! # 防路径穿越:条目名**从不**当路径用 +//! +//! 本模块只写**一个**文件,路径是自己在暂存目录里挑的名字;归档里那些条目名只用于 +//! ①挑候选 ②错误信息。所以「`../../etc/passwd`」这类条目即使存在也落不到盘上。除此之外还挡两道: +//! +//! * [`is_launcher_candidate`] 只放行**普通文件**(目录 / 符号链接 / 硬链接 / 设备条目一律不算 +//! 候选),且条目名必须是相对路径、不含 `..`; +//! * 候选必须**恰好一个**:0 个或 ≥2 个都拒(凭空猜「哪个是启动器」比装失败更糟)。 +//! +//! # 三种失败各有具名错误(都能归因到「谁的错」) +//! +//! | 情况 | 分类 | +//! |---|---| +//! | 归档里没有唯一的本体(0 个 / 不止一个) | [`UpdateErrorKind::LauncherMissing`] | +//! | 本体不是 `target` 那个平台的可执行文件(魔数不对) | [`UpdateErrorKind::LauncherNotExecutable`] | +//! | 本体 0 字节 / 超过上限 / 与归档头声明的大小不符 | [`UpdateErrorKind::LauncherSizeMismatch`] | +//! +//! 三条都是**发布侧**问题(换源、重试、隔一会儿再试都不会变好),所以都不算可重试。 +//! 「清单说是归档、字节却不是 tar(.gz)」不在上表:那是清单与产物对不上,归 +//! [`UpdateErrorKind::InvalidManifest`]。 +//! +//! # 只认 tar(.gz),**不认 zip** +//! +//! 发布矩阵只出 `.tar.gz`(`make_artifacts.py` 的 `SLOTS`),而 tar 头里有 `mode` 字段—— +//! 打包脚本给启动器写 `0o755`、给随包文本写 `0o644`,于是「哪个条目是本体」有客观判据。 +//! zip 没有等价约定(unix 模式位住在外部属性高 16 位,DOS 写的 zip 那一格是另一种语义), +//! 挑错本体等于装错文件——所以 zip 在这里是**明确拒绝**(`InvalidManifest` 且文案点名原因), +//! 不是猜。真要发 zip,先定义「怎么挑本体」,再回来改这里。 +//! +//! # 为什么不是复用 `core::game::java::extract` +//! +//! 那一份是「把归档摊成一棵目录树」:它用 `tar::Entry::unpack_in` 逐条目落盘,语义是 +//! 「解压一个 JDK」。本模块一条都不要:只有一个文件、路径由自己定、大小要上限、且要看魔数。 +//! 搬过来会得到一份「改得面目全非的复制品」,两份代码反而更容易漂(M307 抓到的正是两份判据漂了)。 +//! 共同的**实现手段**(`flate2` / `tar`)当然是同一套——这也是本 mission 选「真做」而不是 +//! 「检查阶段过滤」的理由之一:零新依赖。 + +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Component, Path, PathBuf}; + +use crate::platform::os_info::OsFamily; + +use super::error::{UpdateError, UpdateErrorKind}; +use super::manifest::Artifact; +use super::target::PackageFormat; +use super::verify::StagedPackage; + +/// 本体大小上限(防解压炸弹:归档可能只有几 MB,摊开却是几十 GB)。 +/// +/// 启动器本体实测 ~20 MB 量级,256 MiB 留了一个数量级的余量;真撞上说明包不对, +/// 而不是「上限太紧」。 +pub(super) const MAX_LAUNCHER_BYTES: u64 = 256 * 1024 * 1024; + +/// 归档形态的产物 → 可以直接交给 `install_staged` 的启动器本体。 +/// +/// 裸可执行([`PackageFormat::PortableExe`])原样返回(只补一次可执行位);归档 +/// ([`PackageFormat::PortableZip`])拆出本体并落成暂存目录里的一个临时文件,由返回的 +/// [`StagedPackage`] 在 `Drop` 时清掉。 +/// +/// # Errors +/// +/// 见模块文档的三张表;字节不是 tar(.gz) 是 `InvalidManifest`。 +pub(super) fn launcher_binary( + staged: StagedPackage, + artifact: &Artifact, +) -> Result { + if artifact.format != PackageFormat::PortableZip { + make_executable(staged.path())?; + return Ok(staged); + } + let format = sniff(staged.path())?; + let found = list(staged.path(), format)?; + let name = pick_candidate(&found, staged.path())?.name.clone(); + let out = temp_path(staged.path()); + let written = tar::extract(staged.path(), format, &name, &out)?; + // 归档本身用完就走:`staged` 在这里被 drop(`StagedPackage::Drop` 删暂存文件), + // 返回的那一份指向拆出来的本体。 + adopt(&out, written, &name, artifact, &staged) +} + +/// 候选条目(只有名字:它进错误信息,也是第二次读时匹配那一条的键;**从不**作为落盘路径)。 +/// +/// 大小**不在**这里粗判:真正的门槛在 `tar::write_entry`(声明 0 / 超上限 / 写出字节数与声明不符), +/// 那里已经有归档头声明的大小,另存一份只会让「谁说了算」多一个答案。 +#[derive(Debug)] +struct Candidate { + /// 归档内的条目名。 + name: String, +} + +mod tar; + +/// 归档容器(按魔数判,不看扩展名)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Container { + /// gzip 包着的 tar(发布矩阵里 Linux/macOS 的 `.tar.gz`)。 + TarGz, + /// 裸 tar(有人手打 `tar -cf`,扩展名一样是 `.tar`)。 + Tar, +} + +/// 前 512 字节够判两种容器(tar 的 `ustar` 魔数在偏移 257)。 +fn sniff(path: &Path) -> Result { + let mut head = [0_u8; 512]; + let read = read_head(path, &mut head)?; + let head = &head[..read]; + if head.starts_with(&[0x1f, 0x8b]) { + return Ok(Container::TarGz); + } + if head.get(257..262) == Some(b"ustar") { + return Ok(Container::Tar); + } + let detail = if head.starts_with(b"PK\x03\x04") || head.starts_with(b"PK\x05\x06") { + "是 zip:发布矩阵只出 `.tar.gz`,而 zip 没有可靠的「哪个条目是可执行文件」判据\ + (unix 模式位 vs DOS 属性),所以不猜——发包侧请改用 tar.gz" + } else { + "既不是 tar.gz 也不是 tar:前 512 字节对不上任何归档魔数" + }; + Err(UpdateError::invalid_manifest(format!( + "清单说 {} 是归档形态,但 {} {detail}", + PackageFormat::PortableZip.as_str(), + path.display() + )) + .with_path(path)) +} + +fn read_head(path: &Path, head: &mut [u8]) -> Result { + let mut file = File::open(path).map_err(|error| UpdateError::io_at(path, &error))?; + let mut filled = 0; + while filled < head.len() { + let read = file + .read(&mut head[filled..]) + .map_err(|error| UpdateError::io_at(path, &error))?; + if read == 0 { + break; + } + filled += read; + } + Ok(filled) +} + +/// 列出归档里的**启动器候选**(普通文件 + 可执行位 / `.exe` 名字)。 +fn list(path: &Path, format: Container) -> Result, UpdateError> { + tar::list(path, format == Container::TarGz) +} + +/// 归档条目算不算「启动器本体」的候选。 +/// +/// 两条判据取并集,理由都是**发布侧的写法**: +/// +/// | 判据 | 谁在用 | +/// |---|---| +/// | 有可执行位(`mode & 0o111`) | `make_artifacts.py` 给启动器打 `0o755`、给随包文本打 `0o644` | +/// | 名字以 `.exe` 结尾(不区分大小写) | Windows 那套文件名约定(归档里没有 unix 权限位时) | +/// +/// 另外挡掉畸形的条目名:绝对路径、`..`、空名。本模块**不**拿条目名当落盘路径(见模块文档), +/// 这条只是「连候选都不给它」。 +fn is_launcher_candidate(name: &str, mode: u32) -> bool { + if mode & 0o111 != 0 || name.to_ascii_lowercase().ends_with(".exe") { + return plainly_relative(name); + } + false +} + +/// 条目名是不是一个「规规矩矩的相对路径」(没有盘符/根、没有 `..`、没有空分量)。 +fn plainly_relative(name: &str) -> bool { + let path = Path::new(name); + !name.is_empty() + && !path.is_absolute() + && path + .components() + .all(|component| matches!(component, Component::Normal(_) | Component::CurDir)) +} + +/// 候选必须恰好一个:0 个(归档里没本体)与 ≥2 个(猜不出该装哪个)都拒。 +fn pick_candidate<'a>( + found: &'a [Candidate], + archive: &Path, +) -> Result<&'a Candidate, UpdateError> { + match found { + [only] => Ok(only), + [] => Err(UpdateError::new( + UpdateErrorKind::LauncherMissing, + format!( + "归档 {} 里没有启动器本体:可执行位(0o111)或 `.exe` 名字的普通文件一个都没有\ + (发布脚本是不是漏了给启动器 0o755?)", + archive.display() + ), + ) + .with_path(archive)), + many => Err(UpdateError::new( + UpdateErrorKind::LauncherMissing, + format!( + "归档 {} 里有 {} 个像启动器本体的条目({}),无法确定该装哪一个", + archive.display(), + many.len(), + many.iter() + .map(|candidate| candidate.name.as_str()) + .collect::>() + .join(" / ") + ), + ) + .with_path(archive)), + } +} + +/// 拆出来的本体落在哪:与归档暂存文件同目录(跨卷那一跳由 `install` 负责,这里不做假设)。 +fn temp_path(archive: &Path) -> PathBuf { + let name = archive.file_name().map_or_else( + || String::from("launcher"), + |name| name.to_string_lossy().into_owned(), + ); + archive.with_file_name(format!("{name}-launcher")) +} + +/// 本体过三关(大小、魔数、可执行位)之后包成暂存包。 +fn adopt( + out: &Path, + written: u64, + name: &str, + artifact: &Artifact, + archive: &StagedPackage, +) -> Result { + let mut head = [0_u8; 4]; + let read = read_head(out, &mut head)?; + if !looks_startable(&head[..read], artifact.target.os) { + let _ = fs::remove_file(out); + return Err(UpdateError::new( + UpdateErrorKind::LauncherNotExecutable, + format!( + "归档里的 `{name}` 不是 {} 平台的可执行文件(前 {} 字节是 {},{} 要的魔数不是它;\ + 发布侧把别的平台的产物挂到了这个 target 上)", + artifact.target.as_str(), + read, + hex(&head[..read]), + artifact.target.as_str() + ), + ) + .with_path(archive.path())); + } + make_executable(out)?; + // 摘要沿用**归档**的:拆出来的字节由「归档过了双检」背书(模块文档「先验签再拆包」)。 + Ok(StagedPackage::adopt( + out.to_path_buf(), + written, + archive.integrity().clone(), + )) +} + +/// 前几个字节判「是不是这个平台的可执行文件」。 +/// +/// 与 `packaging/make_artifacts.py::image_arch` 同一套魔数(打包侧用来拒绝「按 win-x64 命名却是 +/// arm64 的 exe」,这里是它的运行期镜像):归档里的字节由整包摘要背书,清单**没有**说 +/// 「里头的这一个是不是本平台的」——所以只能自己看。 +fn looks_startable(head: &[u8], os: OsFamily) -> bool { + /// Mach-O:64 位小端/大端 + universal(32/64)。 + const MACHO: [[u8; 4]; 4] = [ + [0xcf, 0xfa, 0xed, 0xfe], + [0xfe, 0xed, 0xfa, 0xcf], + [0xca, 0xfe, 0xba, 0xbe], + [0xca, 0xfe, 0xba, 0xbf], + ]; + match os { + OsFamily::Windows => head.starts_with(b"MZ"), + OsFamily::Linux => head.starts_with(b"\x7fELF"), + OsFamily::MacOs => MACHO.iter().any(|magic| head.starts_with(magic)), + } +} + +fn hex(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" ") +} + +/// 补可执行位。 +/// +/// 拆出来的文件是 `File::create` 建的(Unix 上 0o644),裸 exe 产物落暂存时也一样——不补这一下, +/// 装好之后的那份**启动器起不来**(`Permission denied`),而那正是本 mission 要修的那类 +/// 「看起来成功、其实坏了」。Windows 没有这一位,什么都不做。 +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<(), UpdateError> { + use std::os::unix::fs::PermissionsExt; + + let mode = fs::metadata(path) + .map_err(|error| UpdateError::io_at(path, &error))? + .permissions() + .mode(); + if mode & 0o111 == 0o111 { + return Ok(()); + } + fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o755)) + .map_err(|error| UpdateError::io_at(path, &error)) +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path) -> Result<(), UpdateError> { + Ok(()) +} diff --git a/crates/core/src/update/unpack/tar.rs b/crates/core/src/update/unpack/tar.rs new file mode 100644 index 0000000..326321d --- /dev/null +++ b/crates/core/src/update/unpack/tar.rs @@ -0,0 +1,172 @@ +//! tar 的读写细节:列候选、按名字取一个条目写成文件。 +//! +//! 从 `unpack.rs` 拆出来的唯一理由:那边贴着 400 行硬线(AGENTS §3-1)。判据(选谁、多大算合规、 +//! 失败怎么归因)全在父模块与 [`write_entry`],本文件只做「照着头里的字段搬字节」。 + +use std::fs::{self, File}; +use std::io::{self, Read}; +use std::path::Path; + +use flate2::read::GzDecoder; + +use super::{Candidate, Container, MAX_LAUNCHER_BYTES}; +use crate::update::error::{UpdateError, UpdateErrorKind}; + +/// 归档里的候选条目(普通文件 + 可执行位 / `.exe` 名字,判据在父模块)。 +pub(super) fn list(path: &Path, gzip: bool) -> Result, UpdateError> { + let mut found = Vec::new(); + let mut archive = tar_archive(path, gzip)?; + for entry in archive + .entries() + .map_err(|error| archive_error(path, &error))? + { + let entry = entry.map_err(|error| archive_error(path, &error))?; + if !entry.header().entry_type().is_file() { + continue; // 目录 / 符号链接 / 硬链接 / 设备:不是可执行字节 + } + let name = entry + .path() + .map_err(|error| archive_error(path, &error))? + .to_string_lossy() + .into_owned(); + let mode = entry + .header() + .mode() + .map_err(|error| archive_error(path, &error))?; + if super::is_launcher_candidate(&name, mode) { + found.push(Candidate { name }); + } + } + Ok(found) +} + +pub(super) fn tar_archive( + path: &Path, + gzip: bool, +) -> Result>, UpdateError> { + let file = File::open(path).map_err(|error| UpdateError::io_at(path, &error))?; + let reader: Box = if gzip { + Box::new(GzDecoder::new(file)) + } else { + Box::new(file) + }; + Ok(tar::Archive::new(reader)) +} + +pub(super) fn archive_error(path: &Path, error: &dyn std::fmt::Display) -> UpdateError { + UpdateError::new( + UpdateErrorKind::InvalidManifest, + format!("归档 {} 读不出来:{error}", path.display()), + ) + .with_path(path) +} + +/// 把候选条目写成 `out`,返回写出的字节数(失败时 `out` 已删掉)。 +pub(super) fn extract( + archive: &Path, + format: Container, + name: &str, + out: &Path, +) -> Result { + extract_tar(archive, format == Container::TarGz, name, out) +} + +fn extract_tar(archive: &Path, gzip: bool, name: &str, out: &Path) -> Result { + let mut tar = tar_archive(archive, gzip)?; + for entry in tar + .entries() + .map_err(|error| archive_error(archive, &error))? + { + let mut entry = entry.map_err(|error| archive_error(archive, &error))?; + if !entry.header().entry_type().is_file() { + continue; + } + let path = entry + .path() + .map_err(|error| archive_error(archive, &error))? + .to_string_lossy() + .into_owned(); + if path == name { + let declared = entry.size(); + return copy_entry(&mut entry, declared, out, archive); + } + } + let _ = fs::remove_file(out); + Err(missing_on_second_pass(archive, name)) +} + +/// 第二遍找不到同一个条目:归档在两遍之间变了(或者解压实现与列表口径不一致)。 +fn missing_on_second_pass(archive: &Path, name: &str) -> UpdateError { + UpdateError::new( + UpdateErrorKind::LauncherMissing, + format!( + "归档 {} 第二次读时找不到本体条目 `{name}`(同一份文件两遍结果不同)", + archive.display() + ), + ) + .with_path(archive) +} + +/// 真正落盘:写 `out`,边写边数,写完对三件事——声明大小、0 字节、上限。 +fn copy_entry( + reader: &mut dyn Read, + declared: u64, + out: &Path, + archive: &Path, +) -> Result { + let verdict = write_entry(reader, declared, out, archive); + if verdict.is_err() { + let _ = fs::remove_file(out); + } + verdict +} + +fn write_entry( + reader: &mut dyn Read, + declared: u64, + out: &Path, + archive: &Path, +) -> Result { + if declared == 0 { + return Err(size_error( + archive, + "本体是 0 字节(归档头声明的就是 0:发布侧打了一个空文件)", + )); + } + if declared > MAX_LAUNCHER_BYTES { + return Err(size_error( + archive, + &format!("本体声明 {declared} 字节,超过上限 {MAX_LAUNCHER_BYTES} 字节(解压炸弹?)"), + )); + } + let mut file = File::create(out).map_err(|error| UpdateError::io_at(out, &error))?; + // `take` 兜住「头里写的比真有的少」:读得动第 MAX+1 个字节说明实际更大,判失败。 + let mut limited = reader.take(MAX_LAUNCHER_BYTES + 1); + let written = + io::copy(&mut limited, &mut file).map_err(|error| UpdateError::io_at(out, &error))?; + drop(file); + if written > MAX_LAUNCHER_BYTES { + return Err(size_error( + archive, + &format!("本体实际超过上限 {MAX_LAUNCHER_BYTES} 字节(归档头在撒谎)"), + )); + } + if written != declared { + return Err(size_error( + archive, + &format!("本体写出 {written} 字节,归档头声明 {declared} 字节"), + )); + } + Ok(written) +} + +fn size_error(archive: &Path, detail: &str) -> UpdateError { + UpdateError::new( + UpdateErrorKind::LauncherSizeMismatch, + format!( + "归档 {} 里的启动器本体大小不合规:{detail}", + archive.display() + ), + ) + .with_path(archive) +} diff --git a/crates/core/src/update/verify.rs b/crates/core/src/update/verify.rs index df3bc29..8c3829e 100644 --- a/crates/core/src/update/verify.rs +++ b/crates/core/src/update/verify.rs @@ -35,6 +35,21 @@ pub struct StagedPackage { } impl StagedPackage { + /// 把「已经过了校验的文件」包成暂存包(M308:从归档里拆出来的启动器本体)。 + /// + /// `size` 必须是**这个文件**的真实字节数(跨卷复制那一步靠它核对写出的字节数); + /// `integrity` 给**归档**那一份:拆出来的字节由「归档过了长度+摘要双检」背书, + /// 这里不另算一份摘要(多算一份只会让「信谁」变成两个答案)。 + /// + /// `Drop` 的语义与 [`stage`] 产出的完全一致:删掉这个文件(装好之后 `rename` 已经搬走了它)。 + pub(super) fn adopt(path: PathBuf, size: u64, integrity: Integrity) -> Self { + Self { + path, + size, + integrity, + } + } + /// 暂存文件路径。 #[must_use] pub fn path(&self) -> &Path { diff --git a/crates/i18n/src/catalog/golden/tail2.rs b/crates/i18n/src/catalog/golden/tail2.rs index 1306bb6..5a44ea4 100644 --- a/crates/i18n/src/catalog/golden/tail2.rs +++ b/crates/i18n/src/catalog/golden/tail2.rs @@ -221,6 +221,9 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("UpdateBannerDismiss", "Skip this version", "忽略这个版本"), ("UpdateBannerRestartNow", "Restart now", "立即重启"), ("UpdateBannerRetry", "Try again", "重试"), + // M308:验签类(不可重试)失败的那句整话。位置跟着 update 域走(金表是 `Key::ALL` 的逐 index + // 镜像):它排在 `UpdateBannerRetry` 之后、`FontCjk*` 之前,不是文件末尾追加。 + ("UpdateBannerFailedFinal", "This update was refused, and retrying will not help: {reason}", "这次更新已被拒绝(重试没用):{reason}"), // M168:中文字体下载状态行(链尾追加,既有顺序不动) ("FontCjkStatusLabel", "Chinese font", "中文字体"), ("FontCjkStatusNotNeeded", "Not needed for this interface language", "当前界面语言不需要中文字体"), diff --git a/crates/i18n/src/catalog/update.rs b/crates/i18n/src/catalog/update.rs index d513d15..20f7d5f 100644 --- a/crates/i18n/src/catalog/update.rs +++ b/crates/i18n/src/catalog/update.rs @@ -52,6 +52,13 @@ macro_rules! update_entries { UpdateBannerDismiss => ("Skip this version", "忽略这个版本"), UpdateBannerRestartNow => ("Restart now", "立即重启"), UpdateBannerRetry => ("Try again", "重试"), + // M308:验签类失败(`retryable == false`)的整句。前半句是「已拒绝」、后半句点明 + // 「重试没用」——两件事都要有,只藏掉按钮而不说原因等于让用户对着一条红条猜。 + // `{reason}` 由页面传入(presenter 给的分类人话,例如签名对不上 / 钥不认得)。 + UpdateBannerFailedFinal => ( + "This update was refused, and retrying will not help: {reason}", + "这次更新已被拒绝(重试没用):{reason}" + ), } } }; } diff --git a/docs/update-trust-anchor.md b/docs/update-trust-anchor.md new file mode 100644 index 0000000..27454c7 --- /dev/null +++ b/docs/update-trust-anchor.md @@ -0,0 +1,63 @@ +# 自更新的信任锚(`PRODUCTION` 表)到底钉了什么 + +M308 纠偏。本文只做两件事:**作废一条过时读数**,并把「现在钉着什么、怎么当场地看一眼」写清楚。 + +## M254 审计的「正式表为空」已作废 + +M254(CLAIMAUDIT)当时读到 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 +`PINNED-KEYS` 标记之间**是空的**,于是断言「任何正式二进制都验不过任何清单」。**那条读数 +作废**:commit `68362e5`(`feat(core): 钉入生产签名公钥(key_id ull-release)`)已经往 +标记之间钉了一条: + +```text +PinnedKey { key_id: "ull-release", public_key_hex: "1c3687ec…" } +``` + +从那以后,**由 `ull-release` 私钥签过的清单,正式构建会照常验签通过**。M308 之前 `trust.rs` +顶部那段文档还写着「正式表当前是空的(fail-closed)」,与代码直接矛盾——这正是 M307 盘查抓到的 +三处文档不一致之一,M308 已改。 + +「表里有钥」**不**降低 fail-closed 的性质:签名缺失 / 签名文件坏 / `key_id` 不认得 / 签名对不上 +(`UpdateErrorKind` 的签名五兄弟)一律拒装,绝不「没钥就放行」。表里那把钥只是把「验得过」这条 +路打开,不是把闸门拆掉。 + +## 现在的表长什么样 + +| 表 | 内容 | 谁看得见 | +|---|---|---| +| `PRODUCTION` | 1 条:`ull-release` | **所有**构建(含官方发布) | +| `TEST_ONLY` | 1 条:`test-only-key`(种子公开写在 `tests/support.rs`) | 只在 `cfg(test)` / `update-test-source` / `update-test-key` 三档构建里**编进去**;其余构建里它是空表,那串十六进制连字节都不在产物里 | + +三档守门脚本:`packaging/check_test_source_off.py` / `check_test_key_off.py`(发布流水线跑)。 + +## 当场看一眼(只读命令) + +```bash +# ① 信任表里有几条(标记之间) +python3 packaging/sign_manifest.py --check-pinned-keyring + +# ② 就绪度播报(三行:表非空 / 环境里有钥且同源 / 最近一次发布痕迹) +python3 packaging/check_signing_ready.py + +# ③ 直接读表 +sed -n '/PINNED-KEYS/,/PINNED-KEYS/p' crates/core/src/update/trust.rs +``` + +`check_signing_ready.py` 的 ① 那一行的判据是「标记之间有几条 `PinnedKey`」,**不是**「表是不是 +空的」——所以哪天有人把标记之间清空,它会立刻喊未就绪(那句文案里的「空表」说的是那一天)。 + +## 加/换钥的纪律(没变) + +只能**旧钥签新钥**:那次发布的清单同时带旧钥签名,changelog 点名新钥的 `key_id` 与指纹, +然后往 `PINNED-KEYS` 标记之间加一条。步骤全文见 `packaging/docs/signing.md`。 + +## 同一轮纠偏的其它两处(M308) + +* `crates/core/src/update/target.rs` 的 `PackageFormat` 文档此前写「portable-zip / appimage / + portable-exe 才走自替换」,与 `job::self_replaceable`(只认裸 exe)直接矛盾;现在判据是 + **便携 exe + 便携归档**两种,两边逐字一致。 +* Linux/macOS 的 `.tar.gz` 以前「查得到更新、按钮画得出来、点下去必失败」;M308 在 + `crates/core/src/update/unpack.rs` 里补了「校验通过之后拆出启动器本体」这一跳,于是那两平台 + 的自更新**真的能装**。顺序不变:清单的 `size`/`sha256` 仍然只对**归档字节**做双检。 +* 界面「重试」按钮现在按 `retryable` 分流:验签类失败不画重试,改说「这次更新已被拒绝 + (重试没用)」(`Key::UpdateBannerFailedFinal`)。 diff --git a/packaging/README.md b/packaging/README.md index a4c3314..0b58907 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -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)(不含任何真实密钥)。 diff --git a/packaging/check_signing_ready.py b/packaging/check_signing_ready.py index 1f74598..34bc5be 100644 --- a/packaging/check_signing_ready.py +++ b/packaging/check_signing_ready.py @@ -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:])] diff --git a/packaging/check_test_key_off.py b/packaging/check_test_key_off.py index 3289c8e..c1c514d 100644 --- a/packaging/check_test_key_off.py +++ b/packaging/check_test_key_off.py @@ -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)`,也不该打开 diff --git a/packaging/docs/compliance-support-boundaries.md b/packaging/docs/compliance-support-boundaries.md index cd611df..e7f139a 100644 --- a/packaging/docs/compliance-support-boundaries.md +++ b/packaging/docs/compliance-support-boundaries.md @@ -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)不随更新落地:更新只换启动器本体,首次安装的归档里那份仍然有效。 diff --git a/packaging/docs/signing-runbook.md b/packaging/docs/signing-runbook.md index 798728a..cfbef27 100644 --- a/packaging/docs/signing-runbook.md +++ b/packaging/docs/signing-runbook.md @@ -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)。已存在的部分(都能用): | 环节 | 在哪 | 今天的状态 | |---|---|---| diff --git a/packaging/docs/signing.md b/packaging/docs/signing.md index 7c56707..0e7c39b 100644 --- a/packaging/docs/signing.md +++ b/packaging/docs/signing.md @@ -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 diff --git a/packaging/make_manifest.py b/packaging/make_manifest.py index 4f41f91..7c58535 100644 --- a/packaging/make_manifest.py +++ b/packaging/make_manifest.py @@ -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)。