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

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

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

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

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

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

No files matched your search

+8 -9
View File
@@ -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,
),
};
@@ -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 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::<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,
/// 签名对不上清单字节(清单被改过,或签名是给另一份清单的)。
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!(
+25 -7
View File
@@ -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()
)))
+4 -1
View File
@@ -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;
+11 -3
View File
@@ -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,
+10 -7
View File
@@ -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)
+5 -1
View File
@@ -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;
@@ -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<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);
}
/// 归档里没有本体(只有随包文本)⇒ `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` 与指纹。**不允许**在没有旧钥签名的情况下替换这张表:
//! 那等于让「能改仓库的人」变成「能改信任根的人」。
//!
//! # 正式构建的表当前是**空的**(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`):
//!
+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)
}
+15
View File
@@ -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 {
+3
View File
@@ -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", "当前界面语言不需要中文字体"),
+7
View File
@@ -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}"
),
} }
};
}