Merge branch 'feat/m308-updatefix-linux-tar-self-update-tru'
Dev Guards / 架构与合规守门(同 main 口径) (push) Blocked by required conditions
Dev Guards / GPU 证据门禁(同 main 口径;无 GPU 只跑自测) (push) Blocked by required conditions
Dev Guards / fmt (rustfmt --check) (push) Waiting to run
Dev Guards / clippy (ubuntu) (push) Waiting to run
Guards / core 零 UI 守门 (push) Blocked by required conditions
Guards / presenter 零 UI 守门 (push) Blocked by required conditions
Guards / design 零第三方依赖守门 (push) Blocked by required conditions
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Blocked by required conditions
Guards / 文件行数 ≤400 守门 (push) Blocked by required conditions
Guards / 依赖许可与来源(cargo-deny) (push) Blocked by required conditions
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Blocked by required conditions
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Blocked by required conditions

This commit is contained in:
AskaEth committed 2026-11-14 00:30:51 +08:00
commit fe54b8f55e
24 files changed
+1239 -60

No files matched your search

+8 -9
View File
@@ -103,6 +103,11 @@ pub struct Update {
} }
mod busy; mod busy;
// M308:失败提示条的正文与按钮(按 `retryable` 分流——验签类不画「重试」)。
mod failure;
use failure::failed_actions;
pub use failure::failed_body;
impl Update { impl Update {
/// 有没有可装的候选(有的话设置页给「下载并安装」)。 /// 有没有可装的候选(有的话设置页给「下载并安装」)。
@@ -300,16 +305,10 @@ pub fn banner<'a>(
)], )],
Role::PrimaryContainer, Role::PrimaryContainer,
), ),
Banner::Failed { reason, .. } => ( Banner::Failed { reason, retryable } => (
catalog.text(Key::UpdateBannerTitleFailed).to_string(), catalog.text(Key::UpdateBannerTitleFailed).to_string(),
reason.to_string(), failed_body(catalog, reason, retryable),
vec![buttons::text( failed_actions(ui, catalog, retryable),
ui,
Button::new(
catalog.text(Key::UpdateBannerRetry),
Message::Update(UpdateAction::CheckNow),
),
)],
Role::ErrorContainer, Role::ErrorContainer,
), ),
}; };
@@ -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<Element<'a, Message>> {
if !retryable {
return Vec::new();
}
vec![buttons::text(
ui,
Button::new(
catalog.text(Key::UpdateBannerRetry),
Message::Update(UpdateAction::CheckNow),
),
)]
}
+132
View File
@@ -2,8 +2,15 @@
use super::*; 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 unlucky_i18n::Lang;
use crate::theme::Families;
/// 提示条的正文**同时**给体积与说明摘要(mission:提示条含版本号、体积、说明摘要)。 /// 提示条的正文**同时**给体积与说明摘要(mission:提示条含版本号、体积、说明摘要)。
#[test] #[test]
fn the_available_banner_carries_both_size_and_notes() { 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()); update.ready = Some("0.2.3".to_string());
assert!(matches!(update.banner(), Some(Banner::Ready { .. }))); 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::<usize>()
}
/// 这条结论画出来的提示条里有几枚按钮(**真控件树**)。
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(...):这句话不见了就等于「重试没用」又被吞掉"
);
}
+16
View File
@@ -48,6 +48,16 @@ pub enum UpdateErrorKind {
SignatureUnknownKey, SignatureUnknownKey,
/// 签名对不上清单字节(清单被改过,或签名是给另一份清单的)。 /// 签名对不上清单字节(清单被改过,或签名是给另一份清单的)。
SignatureInvalid, SignatureInvalid,
/// 归档形态的产物里没有**唯一**的启动器本体(0 个候选,或不止一个)。
///
/// 发布侧问题(打包脚本漏了可执行位、或一个归档里塞了两个启动器):换源、重试都不变好。
LauncherMissing,
/// 归档里取出的本体不是 `target` 那个平台的可执行文件(魔数对不上)。
///
/// 也是发布侧问题:`win-x64` 挂了个 ELF、或 `linux-x64` 挂了个 PE,都是清单写错。
LauncherNotExecutable,
/// 归档里取出的本体大小不合规:0 字节、超过上限、或与归档头声明的不符。
LauncherSizeMismatch,
} }
impl UpdateErrorKind { impl UpdateErrorKind {
@@ -69,6 +79,9 @@ impl UpdateErrorKind {
Self::KeyringEmpty => "keyring_empty", Self::KeyringEmpty => "keyring_empty",
Self::SignatureUnknownKey => "signature_unknown_key", Self::SignatureUnknownKey => "signature_unknown_key",
Self::SignatureInvalid => "signature_invalid", 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] #[must_use]
pub const fn is_retryable(self) -> bool { pub const fn is_retryable(self) -> bool {
matches!( matches!(
+25 -7
View File
@@ -45,6 +45,7 @@ use super::manifest::Artifact;
use super::source::DirSource; use super::source::DirSource;
use super::source::UpdateSource; use super::source::UpdateSource;
use super::target::{PackageFormat, Target}; use super::target::{PackageFormat, Target};
use super::unpack;
use super::verify::stage; use super::verify::stage;
use super::version::Version; use super::version::Version;
@@ -185,8 +186,12 @@ impl Job {
// 先按包形态挡一次,**挡在取包之前**:既不浪费下载,也不会在目标路径上留下任何东西。 // 先按包形态挡一次,**挡在取包之前**:既不浪费下载,也不会在目标路径上留下任何东西。
ensure_self_replaceable(&candidate.artifact)?; ensure_self_replaceable(&candidate.artifact)?;
let staged = stage(&*self.source, &candidate.artifact, &self.staging)?; 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(); 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 }), Ok(receipt) => Ok(Applied::Installed { receipt, version }),
Err(failure) => Err(failure.error), 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()`) | /// | `msi` / `dmg` / `deb` / `rpm` | D50 明说「不做 MSI」——那是系统包管理器的活(`needs_installer()`) |
/// | `portable-zip` | 是压缩包,不是启动器本体:rename 到 exe 路径只会得到一个跑不起来的文件 | /// | `appimage` | 本模块不做 AppImage 的安装语义(`.AppImage` 落到哪、desktop 集成谁做);发布矩阵目前也不发它 |
/// | `appimage` | Linux 的可执行镜像,Windows/macOS 上没有意义;且本模块只做「同目录改名换 exe」 | ///
/// 「`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 { pub(super) fn self_replaceable(format: PackageFormat) -> bool {
matches!(format, PackageFormat::PortableExe) matches!(
format,
PackageFormat::PortableExe | PackageFormat::PortableZip
)
} }
/// 包形态不合规就拒绝(错误分类是 `InvalidManifest`:清单挂错了东西,换源/重试都没用)。 /// 包形态不合规就拒绝(错误分类是 `InvalidManifest`:清单挂错了东西,换源/重试都没用)。
@@ -212,8 +230,8 @@ fn ensure_self_replaceable(artifact: &Artifact) -> Result<(), UpdateError> {
return Ok(()); return Ok(());
} }
Err(UpdateError::invalid_manifest(format!( Err(UpdateError::invalid_manifest(format!(
"发布里的 {} 产物是 {} 形态,不能原地替换启动器:本启动器只做便携单文件 exe 的自替换\ "发布里的 {} 产物是 {} 形态,不能变成启动器本体:本启动器只做便携 exe / 便携归档的自替换\
(安装器形态请用系统包管理器;压缩包请解压后再用)", (安装器形态请用系统包管理器)",
artifact.target.as_str(), artifact.target.as_str(),
artifact.format.as_str() artifact.format.as_str()
))) )))
+4 -1
View File
@@ -19,6 +19,7 @@
//! │ Available(candidate) //! │ Available(candidate)
//! ├─ UpdateChecker::prepare(candidate, staging) 取包 → 长度/摘要双检 //! ├─ UpdateChecker::prepare(candidate, staging) 取包 → 长度/摘要双检
//! │ │ StagedPackage(校验通过,失败已自动清理) //! │ │ StagedPackage(校验通过,失败已自动清理)
//! │ ├─ unpack::launcher_binary(...) 归档才拆:取出启动器本体(M308)
//! │ └─ install::install_staged(...) 改名旧版 → 搬入新版(失败回滚) //! │ └─ install::install_staged(...) 改名旧版 → 搬入新版(失败回滚)
//! └─ UpToDate / MissingArtifact //! └─ UpToDate / MissingArtifact
//! ``` //! ```
@@ -33,7 +34,8 @@
//! signature 清单签名信封 + 验签 trust 钉进二进制的公钥表(keyring) //! signature 清单签名信封 + 验签 trust 钉进二进制的公钥表(keyring)
//! ed25519 Ed25519 严格验签(ed25519-dalek::verify_strict 的薄封装) //! ed25519 Ed25519 严格验签(ed25519-dalek::verify_strict 的薄封装)
//! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点) //! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点)
//! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口) //! unpack 归档形态的产物 → 启动器本体 error 分类与文案
//! job 检查/应用的一条龙(GUI 与 CLI 共用同一入口)
//! ``` //! ```
//! //!
//! # 跨卷(M171) //! # 跨卷(M171)
@@ -77,6 +79,7 @@ mod signature;
pub mod source; pub mod source;
pub mod target; pub mod target;
mod trust; mod trust;
mod unpack;
pub mod verify; pub mod verify;
pub mod version; pub mod version;
+11 -3
View File
@@ -132,8 +132,12 @@ impl fmt::Display for Target {
/// 安装包形态。 /// 安装包形态。
/// ///
/// 只区分「安装器装出来的」与「解压即用的」两大类,具体怎么装由打包层决定: /// 只区分「安装器装出来的」与「解压即用的」两大类,具体怎么装由打包层决定:
/// 安装器装的实例(msi/dmg/deb/rpm)本模块**不主张**原地覆盖(见 `install` 模块文档), /// 安装器形态(msi/dmg/deb/rpm)本模块**不主张**原地覆盖(见 `install` 模块文档);
/// 便携形态(portable-zip / appimage / portable-exe)才走自替换。 /// 便携形态才走自替换,而**哪两种便携形态真的能变成启动器本体,判据只有一处**:
/// [`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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum PackageFormat { pub enum PackageFormat {
/// Windows 安装器。 /// Windows 安装器。
@@ -146,7 +150,11 @@ pub enum PackageFormat {
Deb, Deb,
/// RPM 包。 /// RPM 包。
Rpm, Rpm,
/// 便携 zip(解压即用)。 /// 便携归档(解压即用):清单里 Linux/macOS 的 `.tar.gz` 用的就是它。
///
/// 名字里的 `zip` 是历史包袱(DTO 早先只打算发 zip);`parse` 同时认 `zip` 与
/// `portable-zip`,而**字节的容器由魔数判**(tar.gz / tar / zip 三种都认,见
/// [`super::unpack`])——扩展名与清单词面都不能当判据。
PortableZip, PortableZip,
/// 便携单文件 exe。 /// 便携单文件 exe。
PortableExe, PortableExe,
+10 -7
View File
@@ -49,24 +49,27 @@ fn fixture(format: &str, version: &str) -> (TempDir, Job) {
(dir, job) (dir, job)
} }
/// 只有便携单文件 exe 能原地变成启动器本体。 /// 能变成启动器本体的形态**只有两种**:裸 exe 与便携归档。
/// ///
/// 抓得住:把闸门写成 `!needs_installer()`(那样 `portable-zip` 会漏过去,zip 会被 rename 成 /// 抓得住两边的错法:把闸门放宽成「全格式都算」(`msi`/`deb` 会被改名成启动器)、
/// exe,得到一个跑不起来的启动器)。 /// 或者收回成「只认裸 exe」(Linux/macOS 的 `.tar.gz` 重新变成「查得到、点必失败」——M307 那条)。
#[test] #[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::PortableExe));
assert!(
self_replaceable(PackageFormat::PortableZip),
"Linux/macOS 的 `.tar.gz` 在清单里就是这一档(M308)"
);
for format in [ for format in [
PackageFormat::Msi, PackageFormat::Msi,
PackageFormat::Dmg, PackageFormat::Dmg,
PackageFormat::Deb, PackageFormat::Deb,
PackageFormat::Rpm, PackageFormat::Rpm,
PackageFormat::AppImage, PackageFormat::AppImage,
PackageFormat::PortableZip,
] { ] {
assert!( assert!(
!self_replaceable(format), !self_replaceable(format),
"{} 不该被当成可原地自替换的形态", "{} 不该被当成能变成启动器本体的形态",
format.as_str() format.as_str()
); );
} }
@@ -75,7 +78,7 @@ fn only_portable_exe_is_self_replaceable() {
/// 安装器形态被拒绝,且**目标文件一个字节都没动、暂存目录没建**(闸门在取包之前)。 /// 安装器形态被拒绝,且**目标文件一个字节都没动、暂存目录没建**(闸门在取包之前)。
#[test] #[test]
fn an_installer_artifact_is_refused_before_any_download() { 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 (dir, job) = fixture(format, "9.9.9");
let error = job let error = job
.apply(&Version::parse("0.2.2").expect("版本"), Channel::Stable) .apply(&Version::parse("0.2.2").expect("版本"), Channel::Stable)
+5 -1
View File
@@ -10,9 +10,12 @@
//! 跨卷(暂存在 A 卷、目标在 B 卷)安装成功且字节完整、中途失败不留半截文件、 //! 跨卷(暂存在 A 卷、目标在 B 卷)安装成功且字节完整、中途失败不留半截文件、
//! 同卷不多搬一个字节、旧备份只留最近一份(崩溃现场的唯一旧副本例外)、 //! 同卷不多搬一个字节、旧备份只留最近一份(崩溃现场的唯一旧副本例外)、
//! 跨卷守卫「探不到第二块卷」时不许静默变绿(裁决用例见 `install_xdev_cases`); //! 跨卷守卫「探不到第二块卷」时不许静默变绿(裁决用例见 `install_xdev_cases`);
//! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、 //! * 一条龙(`job`):只有便携 exe 与便携归档能变成启动器本体(安装器形态在取包之前就被拒)、
//! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来, //! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来,
//! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给); //! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给);
//! * 归档拆本体(`unpack`):Linux `.tar.gz` / macOS zip 走完整条链(旧版进备份),以及三种具名
//! 失败——归档里没有唯一本体、本体不是本平台的可执行文件、本体大小不合规(另加「不是归档」)。
//! 条目名带 `..` 的不算候选;暂存目录在失败后不留残渣;
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝; //! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝;
//! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。 //! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。
//! //!
@@ -32,5 +35,6 @@ mod source_cases;
mod support; mod support;
mod support_sign; mod support_sign;
mod target_cases; mod target_cases;
mod unpack_cases;
mod verify_cases; mod verify_cases;
mod version_cases; mod version_cases;
@@ -0,0 +1,320 @@
//! 归档形态的产物 → 启动器本体(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<u8> {
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<u8> {
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<u8> {
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<dyn UpdateSource>,
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::<String>::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);
}
/// Windows 的名字兜底:归档里的 `unlucky-launcher.exe` 带 **0644**(没有可执行位)也认。
///
/// 为什么需要这一条:`is_launcher_candidate` 是两条判据取并集(可执行位 **或** `.exe` 名字)。
/// 发布矩阵里 Windows 发裸 `.exe`,可「清单给 win-* 挂一个归档」是合法输入,而 tar 里没有 unix
/// 权限位、`make_artifacts.py` 也只给启动器打 0o755——名字这条兜底不能是空转的。
///
/// 抓得住:把 `is_launcher_candidate` 里 `|| name.to_ascii_lowercase().ends_with(".exe")` 删掉,
/// 本用例立刻红(归档里一个候选都没有 ⇒ `LauncherMissing`)。
#[test]
fn a_windows_exe_without_the_exec_bit_is_still_the_launcher() {
let stage = "unlucky-launcher_9.9.9_windows_x64";
let archive = tar_gz(&[
(&format!("{stage}/unlucky-launcher.exe"), PE, 0o644),
(&format!("{stage}/LICENSE"), b"license text", 0o644),
]);
let target = Target::new(OsFamily::Windows, Arch::X64);
let (_dir, job) = fixture(target, "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("读目标"), PE);
}
/// 归档里没有本体(只有随包文本)⇒ `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()
);
}
}
+10 -5
View File
@@ -21,12 +21,17 @@
//! 以及 changelog 里点名新钥的 `key_id` 与指纹。**不允许**在没有旧钥签名的情况下替换这张表: //! 以及 changelog 里点名新钥的 `key_id` 与指纹。**不允许**在没有旧钥签名的情况下替换这张表:
//! 那等于让「能改仓库的人」变成「能改信任根的人」。 //! 那等于让「能改仓库的人」变成「能改信任根的人」。
//! //!
//! # 正式构建的表当前是**空的**(fail-closed) //! # 正式构建的表里有**一把**钥(`ull-release`,commit `68362e5` 起)
//! //!
//! 私钥只能来自仓库 Secret,本轮没有(也不该由代码仓库产生)私钥,所以正式表里一个条目都没有: //! ⚠ 早先这里写的是「正式表当前是空的(fail-closed)」——**那句话已作废**(M254 审计留下的旧口径,
//! 任何正式构建拿到清单都会以 [`super::error::UpdateErrorKind::SignatureMissing`] 之类的分类拒绝更新, //! M308 纠偏;作废声明另见 `docs/update-trust-anchor.md`)。现在 `PRODUCTION` 里钉着一条
//! 而 `packaging/sign_manifest.py --check-pinned-keyring` 会让发布流水线在「Secret 没配 / 公钥没钉」 //! `PinnedKey { key_id: "ull-release", … }`:正式构建拿到**由这把私钥签过**的清单会照常验签通过。
//! 时**当场红**(宁可不出 Release,也不出未签名的 Release)。 //!
//! 仍然 fail-closed 的是「表里一把都没匹配上」的那些情况——签名缺失 / 坏掉 / 钥不认得 / 签名对不上
//! ([`super::error::UpdateErrorKind`] 的签名五兄弟),它们一律拒装,绝不「没钥就放行」。
//! 而「Secret 没配 / 公钥没钉」这类**发布侧**的漏项由
//! `packaging/sign_manifest.py --check-pinned-keyring` 在流水线上**当场红**
//! (宁可不出 Release,也不出未签名的 Release)。
//! //!
//! 录一把真钥的两步(细节见 `packaging/docs/signing.md`): //! 录一把真钥的两步(细节见 `packaging/docs/signing.md`):
//! //!
+320
View File
@@ -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<StagedPackage, UpdateError> {
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<Container, UpdateError> {
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<usize, UpdateError> {
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<Vec<Candidate>, 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::<Vec<_>>()
.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<StagedPackage, UpdateError> {
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::<Vec<_>>()
.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(())
}
+172
View File
@@ -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<Vec<Candidate>, 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<tar::Archive<Box<dyn Read>>, UpdateError> {
let file = File::open(path).map_err(|error| UpdateError::io_at(path, &error))?;
let reader: Box<dyn Read> = 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<u64, UpdateError> {
extract_tar(archive, format == Container::TarGz, name, out)
}
fn extract_tar(archive: &Path, gzip: bool, name: &str, out: &Path) -> Result<u64, UpdateError> {
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<u64, UpdateError> {
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<u64, UpdateError> {
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)
}
+24 -2
View File
@@ -35,19 +35,41 @@ pub struct StagedPackage {
} }
impl 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] #[must_use]
pub fn path(&self) -> &Path { pub fn path(&self) -> &Path {
&self.path &self.path
} }
/// 实际字节数(已与清单核对过,等于清单声明的 `size`)。 /// 实际字节数(**这个文件**写出来的字节数)。
///
/// `stage` 产出的那份就是清单声明的 `size`(双检已核对);M308 起 `unpack` 还会用
/// [`Self::adopt`] 造出「从归档里拆出来的本体」那一份,那时它是**本体**的字节数,
/// 不是归档的。跨卷复制那一步靠它核对该搬多少字节,所以这个字段必须始终是「本文件的」。
#[must_use] #[must_use]
pub const fn size(&self) -> u64 { pub const fn size(&self) -> u64 {
self.size self.size
} }
/// 校验用的摘要。 /// 为这个文件背书的那份摘要。
///
/// `stage` 产出的是清单里写的那条摘要(对**下载到的字节**算的);`adopt` 产出的(拆出来的本体)
/// 沿用**归档**的摘要——拆出来的字节由「归档过了长度+摘要双检」背书,本模块不为它另算一份。
#[must_use] #[must_use]
pub fn integrity(&self) -> &Integrity { pub fn integrity(&self) -> &Integrity {
&self.integrity &self.integrity
+3
View File
@@ -221,6 +221,9 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("UpdateBannerDismiss", "Skip this version", "忽略这个版本"), ("UpdateBannerDismiss", "Skip this version", "忽略这个版本"),
("UpdateBannerRestartNow", "Restart now", "立即重启"), ("UpdateBannerRestartNow", "Restart now", "立即重启"),
("UpdateBannerRetry", "Try again", "重试"), ("UpdateBannerRetry", "Try again", "重试"),
// M308:验签类(不可重试)失败的那句整话。位置跟着 update 域走(金表是 `Key::ALL` 的逐 index
// 镜像):它排在 `UpdateBannerRetry` 之后、`FontCjk*` 之前,不是文件末尾追加。
("UpdateBannerFailedFinal", "This update was refused, and retrying will not help: {reason}", "这次更新已被拒绝(重试没用):{reason}"),
// M168:中文字体下载状态行(链尾追加,既有顺序不动) // M168:中文字体下载状态行(链尾追加,既有顺序不动)
("FontCjkStatusLabel", "Chinese font", "中文字体"), ("FontCjkStatusLabel", "Chinese font", "中文字体"),
("FontCjkStatusNotNeeded", "Not needed for this interface language", "当前界面语言不需要中文字体"), ("FontCjkStatusNotNeeded", "Not needed for this interface language", "当前界面语言不需要中文字体"),
+7
View File
@@ -52,6 +52,13 @@ macro_rules! update_entries {
UpdateBannerDismiss => ("Skip this version", "忽略这个版本"), UpdateBannerDismiss => ("Skip this version", "忽略这个版本"),
UpdateBannerRestartNow => ("Restart now", "立即重启"), UpdateBannerRestartNow => ("Restart now", "立即重启"),
UpdateBannerRetry => ("Try again", "重试"), UpdateBannerRetry => ("Try again", "重试"),
// M308:验签类失败(`retryable == false`)的整句。前半句是「已拒绝」、后半句点明
// 「重试没用」——两件事都要有,只藏掉按钮而不说原因等于让用户对着一条红条猜。
// `{reason}` 由页面传入(presenter 给的分类人话,例如签名对不上 / 钥不认得)。
UpdateBannerFailedFinal => (
"This update was refused, and retrying will not help: {reason}",
"这次更新已被拒绝(重试没用):{reason}"
),
} } } }
}; };
} }
+67
View File
@@ -0,0 +1,67 @@
# 自更新的信任锚(`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
# ① 就绪度播报:第一行就是「标记之间有几条 PinnedKey」,只读、不需要任何私钥
python3 packaging/check_signing_ready.py
# ② 直接读表(最原始的那一眼)
sed -n '/PINNED-KEYS/,/PINNED-KEYS/p' crates/core/src/update/trust.rs
```
`check_signing_ready.py` 的 ① 那一行是**读表**的正确入口:它按标记解析本文件、数条目并点名
`key_id` 与公钥前缀。它的退出码是**就绪度**(① 表非空 **且** ② 环境里有同源私钥才 exit 0),
不是「表读到了没有」——没配 Secret 的机器上它照样打出 ① 那一行,只是整体 exit 1。
⚠ **别拿 `sign_manifest.py --check-pinned-keyring` 当读表工具**:它要私钥
(`ULL_MANIFEST_SIGNING_KEY` 或 `--key <文件>`),语义是「**Secret 里那把钥**是否已钉住」这条
**发布前门禁**;没钥时它直接 `error: 没有签名密钥…` + exit 1(M308 r1 实测)。想只读看表就用上面两条。
`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`)。
+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 - 验收本体:`cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_test
--test update_e2e_tamper_test`(正路 + 幂等 / 篡改摘要 + 篡改包体 + 截断)。CI 里同一个命令挂在 --test update_e2e_tamper_test`(正路 + 幂等 / 篡改摘要 + 篡改包体 + 截断)。CI 里同一个命令挂在
`ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` + `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 点击与重启替换(需人工)。 **不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。
## 15. 产物 PE 子系统(M196:GUI 必须 `WINDOWS_GUI`=2) ## 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` 里的 的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的
**编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装, **编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装,
**不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。 **不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。
- **签名就绪度与操作步骤(M255)**:今天 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的** ⇒ 任何正式 - **签名就绪度与操作步骤(M255)**:`PRODUCTION` 表在 `PINNED-KEYS` 标记之间钉着一条 `ull-release`
二进制都验不过任何清单(fail-closed)。读数一条命令:`python3 packaging/check_signing_ready.py` (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 (三行 + `0/1/2`,未就绪时打印一行修复命令);CI 里同一条读数在 `signing-status.yml` 的 job summary
上,**那是播报不是门禁**(不应设为 required)。要动手做的那 5 步在 上,**那是播报不是门禁**(不应设为 required)。要动手做的那 5 步在
[`docs/signing-runbook.md`](docs/signing-runbook.md)(不含任何真实密钥)。 [`docs/signing-runbook.md`](docs/signing-runbook.md)(不含任何真实密钥)。
+13 -8
View File
@@ -1,17 +1,21 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""签名就绪度播报(M255):**只读**、零第三方依赖,三行说清「今天能不能签发一份客户端会认的清单」。 """签名就绪度播报(M255,**M308 纠偏**):**只读**、零第三方依赖,三行说清「今天能不能签发一份客户端会认的清单」。
为什么要它:M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 为什么要它:M254(CLAIMAUDIT)当时查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在
`PINNED-KEYS` 标记之间是**空的**,而空表一律 `KeyringEmpty` 拒装 —— 于是**今天任何正式二进制都验不过 `PINNED-KEYS` 标记之间是**空的**,而空表一律 `KeyringEmpty` 拒装 —— 那样任何正式二进制都验不过任何
任何清单**(fail-closed 是成立的,但在用户机器上表现为「更新永远失败」)。工具链早就齐了 清单(fail-closed 成立,但在用户机器上表现为「更新永远失败」)。**那条读数已经过时**:commit
(`sign_manifest.py` 的 `--generate-key` / `--check-pinned-keyring` / `--sign` / `--verify-pinned`), `68362e5` 起 `PRODUCTION` 里钉着一条 `ull-release`,今天的正式构建认这把钥签的清单。本脚本照旧
缺的是**操作步骤与可见性**。本脚本只负责「现在差什么」,它不生成钥、不写任何文件、不改 `trust.rs`。 只负责「现在差什么」:它不生成钥、不写任何文件、不改 `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` 当场红 | | ② | 环境里有签名钥:`ULL_MANIFEST_SIGNING_KEY`(或 `--key <文件>`)能解出 32 字节种子,**且**它的公钥在 ① 的表里 | 没钥签不了;钥与信任表不是同一把 ⇒ 发布时 `--check-pinned-keyring` 当场红 |
| ③ | 最近一次发布的 `manifest.json` / `.sig` 痕迹 | **不参与就绪判定**。取不到证据就写「未知」——「本地没留档」不等于「上次没签」 | | ③ | 最近一次发布的 `manifest.json` / `.sig` 痕迹 | **不参与就绪判定**。取不到证据就写「未知」——「本地没留档」不等于「上次没签」 |
@@ -78,7 +82,8 @@ def table_state(path: Path) -> tuple[bool, str, list[tuple[str, str]]]:
if keys: if keys:
names = "、".join(f"{kid}({pk[:16]}…)" for kid, pk in keys) names = "、".join(f"{kid}({pk[:16]}…)" for kid, pk in keys)
return True, f"就绪 —— 标记之间 {len(keys)} 条:{names}({path}:{line_no} 起)", keys return True, f"就绪 —— 标记之间 {len(keys)} 条:{names}({path}:{line_no} 起)", keys
# 空表就是今天的状态。顺手查一个真实易错点:有人把条目贴到标记**外面**(`];` 之后)。 # 标记之间 0 条 = 未就绪(M254 那天就是这个状态;今天表里有 `ull-release`,这条分支只会在
# 有人清空标记之间的内容时亮)。顺手查一个真实易错点:有人把条目贴到标记**外面**(`];` 之后)。
# ⚠ 本仓另有一把 `TEST_ONLY` 测试钥**本来就在标记之外**(那是它的正确位置),所以这里只**点名**, # ⚠ 本仓另有一把 `TEST_ONLY` 测试钥**本来就在标记之外**(那是它的正确位置),所以这里只**点名**,
# 不替人下结论 —— 上一版写成「贴错位置,等于没钉」,在真实 trust.rs 上是误报(实测抓出来的)。 # 不替人下结论 —— 上一版写成「贴错位置,等于没钉」,在真实 trust.rs 上是误报(实测抓出来的)。
strays = [kid for kid, _ in KID_RE.findall(text[:begin]) + KID_RE.findall(text[end:])] 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 #!/usr/bin/env python3
"""守 `update-test-key`:这把**公开**的测试公钥不许进发布构建(M190 r3)。 """守 `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、 测试公钥 `trust::TEST_ONLY` 只在三档「仅测试」构建里并入:`cfg(test)`、测试通道那条 feature、
`update-test-key`。第三档是给 `crates/presenter` 的 `[dev-dependencies]` 用的——presenter 的更新链路 `update-test-key`。第三档是给 `crates/presenter` 的 `[dev-dependencies]` 用的——presenter 的更新链路
用例要构造「本构建认得」的签名清单,而它编译 core 时既没有 `cfg(test)`,也不该打开 用例要构造「本构建认得」的签名清单,而它编译 core 时既没有 `cfg(test)`,也不该打开
@@ -64,9 +64,12 @@ published 一列,Termux 行标注 experimental)——改一边必须同步
`windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**(前者靠 `dumpbin -dependents` 的静态 CRT `windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**(前者靠 `dumpbin -dependents` 的静态 CRT
门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟);本机能做且已做的是脚本层 门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟);本机能做且已做的是脚本层
取证(§7.5:宿主判定注入、可执行文件头识别、把真 exe 的 PE 机器码改成 ARM64 当 stub 走完全流程)。 取证(§7.5:宿主判定注入、可执行文件头识别、把真 exe 的 PE 机器码改成 ARM64 当 stub 走完全流程)。
8. **Linux 上自更新只"能查不能装"**(M184 发布 Linux x64 之后才有意义):清单现在会带 `linux-x64` 的 8. **Linux/macOS 自更新:M308 起能装**(M184 发布 Linux x64 之后这条才真正有意义)。清单里
`portable-zip` 产物,而 `ull update apply` 只做便携单文件 `.exe` 的自替换 `linux-x64` / `mac-*-*` 的产物是 `portable-zip`(真实容器是 `.tar.gz`),M308 之前在
(`crates/core/src/update/job.rs::ensure_self_replaceable`)——Linux 上它会带具名原因**拒绝** `job::ensure_self_replaceable` 处带具名原因**拒绝**;现在 `core::update::unpack` 会在归档字节
("发布里的 linux-x64 产物是 portable-zip 形态,不能原地替换启动器 … 压缩包请解压后再用"), 过完长度/摘要双检之后把里面的启动器本体拆出来,再走同一套原子替换 + 回滚
不会半装坏。Linux 的更新方式仍是「下载新归档覆盖旧副本」。真要做 Linux 自更新(换目录里的文件再重启) (判据表见 `crates/core/src/update/job.rs::self_replaceable` 与 `unpack.rs` 的模块文档)。
是另一件事,未规划。 三条仍然**不做**的事:①不在检查阶段过滤产物形态(清单挂错形态时是「点下去带原因失败」,
不是「装了个坏的」);②不装安装器形态(msi/dmg/deb/rpm 交给系统包管理器);
③不认 zip 容器(tar 头里有 `mode`,能客观判「哪个条目是本体」;zip 没有等价约定,宁可拒绝也不猜)。
随包文本(LICENSE/NOTICE/README)不随更新落地:更新只换启动器本体,首次安装的归档里那份仍然有效。
+12 -4
View File
@@ -1,16 +1,24 @@
# 签名就绪度操作手册(M255) # 签名就绪度操作手册(M255)
> **⚠ M308 纠偏(2026-11-13)**:本文写于「信任表是空的」那些日子。commit `68362e5` 起
> `trust::PRODUCTION` 在 `PINNED-KEYS` 标记之间钉着一条 `ull-release`,所以 M254 那条
> 「任何正式二进制都验不过任何清单」的读数**已作废**(见 `docs/update-trust-anchor.md`)。
> 下文凡出现「表空 ⇒ 今天必然红」「信任表是空的」,请读成「**那天**必然红」;步骤本身没变,
> 只是第 0 节不再是当下的状态,而 §3 的「一次性录入」已经做过一次了。
本文件是**人类照做**的手册:从「信任表是空的」走到「发出去的 Release 用客户端判据能验过」。 本文件是**人类照做**的手册:从「信任表是空的」走到「发出去的 Release 用客户端判据能验过」。
背景/威胁模型/轮换纪律在 [`signing.md`](signing.md);本文件只讲**步骤、命令、坑**。 背景/威胁模型/轮换纪律在 [`signing.md`](signing.md);本文件只讲**步骤、命令、坑**。
> ⚠ 本文件**不含**任何真实私钥、种子或公钥。所有示例都是 `<尖括号占位符>`。 > ⚠ 本文件**不含**任何真实私钥、种子或公钥。所有示例都是 `<尖括号占位符>`。
> `--generate-key` 的输出是**敏感物**:只在你要贴进 Secret 的那一次运行里存在,别存文件、别贴 issue。 > `--generate-key` 的输出是**敏感物**:只在你要贴进 Secret 的那一次运行里存在,别存文件、别贴 issue。
## 0. 现状读一遍(2026-11 的 M254 审计结论) ## 0. 现状读一遍(2026-11 的 M254 审计结论;**已被 M308 取代**)
`crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的**。空表 = M254 审计当时读到 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是
`KeyringEmpty` 一律拒装 ⇒ **今天任何正式二进制都验不过任何清单**(fail-closed 成立,但用户端表现为 **空的**(空表 = `KeyringEmpty` 一律拒装 ⇒ 用户端表现为「更新永远失败」)。**那条读数已作废**:
「更新永远失败」)。已存在的部分(都能用): commit `68362e5` 钉入 `ull-release` 之后,正式构建认得这把钥签的清单。下表「今天」一列也随之为
历史:表**不是**空的了(只读读数见 `python3 packaging/check_signing_ready.py` 的 ① 行,今天打
「就绪 —— 标记之间 1 条:ull-release」),其余两行取决于环境里有没有配 Secret。已存在的部分(都能用):
| 环节 | 在哪 | 今天的状态 | | 环节 | 在哪 | 今天的状态 |
|---|---|---| |---|---|---|
+5 -1
View File
@@ -50,10 +50,14 @@ manifest.json(字节)──签名──> manifest.json.sig
### 空表 = fail-closed ### 空表 = fail-closed
正式表当前**是空的**:私钥只能来自仓库 Secret,任何代码仓库都不该自己生成并落盘私钥。
空表的效果是「任何正式构建都拒装任何清单」,同时 `--check-pinned-keyring` 让发布流水线当场红。 空表的效果是「任何正式构建都拒装任何清单」,同时 `--check-pinned-keyring` 让发布流水线当场红。
**这是有意的**:宁可不出 Release,也不出未签名的 Release。 **这是有意的**:宁可不出 Release,也不出未签名的 Release。
⚠ 正式表**现在不是空的**:commit `68362e5` 钉入了 `ull-release`(M308 纠偏;M254 审计那句
「正式表为空」已作废,见 `docs/update-trust-anchor.md`)。上面那条「空表 ⇒ 拒装」仍然成立——
它说的是「表里一把都没匹配上」时的行为,不是当下的表内容。私钥只能来自仓库 Secret,
任何代码仓库都不该自己生成并落盘私钥(这条没变)。
## 3. 一次性录入(只做一次) ## 3. 一次性录入(只做一次)
```bash ```bash
+5 -2
View File
@@ -45,8 +45,11 @@ MANIFEST_NAME = "manifest.json"
CHANNELS = ("stable", "beta", "alpha", "nightly") CHANNELS = ("stable", "beta", "alpha", "nightly")
# 文件名里的平台段 -> 清单 target 的系统段(`windows_x64` -> `win-x64`)。 # 文件名里的平台段 -> 清单 target 的系统段(`windows_x64` -> `win-x64`)。
PLATFORM_TARGET = {"windows": "win", "macos": "mac", "linux": "linux"} PLATFORM_TARGET = {"windows": "win", "macos": "mac", "linux": "linux"}
# 产物扩展名 -> 清单 format。归档(tar.gz)在 DTO 里只有「解压即用」这一档 `portable-zip`: # 产物扩展名 -> 清单 format。归档(tar.gz)在 DTO 里只有 `portable-zip` 这一档:名字带 zip,
# 名字带 zip,语义是「非安装器、可原地替换」(PackageFormat::needs_installer() == false)。 # 语义是「非安装器、可原地替换」(`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"} EXT_FORMAT = {".exe": "portable-exe", ".tar.gz": "portable-zip"}
GOLDEN = Path(__file__).resolve().parent / "tests" / "manifest.golden.json" GOLDEN = Path(__file__).resolve().parent / "tests" / "manifest.golden.json"
# DTO 侧的取值表镜像(crates/core/src/update/target.rs::parse_os / Arch::parse / PackageFormat::parse)。 # DTO 侧的取值表镜像(crates/core/src/update/target.rs::parse_os / Arch::parse / PackageFormat::parse)。