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:
1 parent
db24379e35
commit
af82658d08
24 files changed
+1201
-58
No files matched your search
@@ -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),
|
||||
),
|
||||
)]
|
||||
}
|
||||
@@ -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(...):这句话不见了就等于「重试没用」又被吞掉"
|
||||
);
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
)))
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`):
|
||||
//!
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", "当前界面语言不需要中文字体"),
|
||||
|
||||
@@ -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}"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# 自更新的信任锚(`PRODUCTION` 表)到底钉了什么
|
||||
|
||||
M308 纠偏。本文只做两件事:**作废一条过时读数**,并把「现在钉着什么、怎么当场地看一眼」写清楚。
|
||||
|
||||
## M254 审计的「正式表为空」已作废
|
||||
|
||||
M254(CLAIMAUDIT)当时读到 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在
|
||||
`PINNED-KEYS` 标记之间**是空的**,于是断言「任何正式二进制都验不过任何清单」。**那条读数
|
||||
作废**:commit `68362e5`(`feat(core): 钉入生产签名公钥(key_id ull-release)`)已经往
|
||||
标记之间钉了一条:
|
||||
|
||||
```text
|
||||
PinnedKey { key_id: "ull-release", public_key_hex: "1c3687ec…" }
|
||||
```
|
||||
|
||||
从那以后,**由 `ull-release` 私钥签过的清单,正式构建会照常验签通过**。M308 之前 `trust.rs`
|
||||
顶部那段文档还写着「正式表当前是空的(fail-closed)」,与代码直接矛盾——这正是 M307 盘查抓到的
|
||||
三处文档不一致之一,M308 已改。
|
||||
|
||||
「表里有钥」**不**降低 fail-closed 的性质:签名缺失 / 签名文件坏 / `key_id` 不认得 / 签名对不上
|
||||
(`UpdateErrorKind` 的签名五兄弟)一律拒装,绝不「没钥就放行」。表里那把钥只是把「验得过」这条
|
||||
路打开,不是把闸门拆掉。
|
||||
|
||||
## 现在的表长什么样
|
||||
|
||||
| 表 | 内容 | 谁看得见 |
|
||||
|---|---|---|
|
||||
| `PRODUCTION` | 1 条:`ull-release` | **所有**构建(含官方发布) |
|
||||
| `TEST_ONLY` | 1 条:`test-only-key`(种子公开写在 `tests/support.rs`) | 只在 `cfg(test)` / `update-test-source` / `update-test-key` 三档构建里**编进去**;其余构建里它是空表,那串十六进制连字节都不在产物里 |
|
||||
|
||||
三档守门脚本:`packaging/check_test_source_off.py` / `check_test_key_off.py`(发布流水线跑)。
|
||||
|
||||
## 当场看一眼(只读命令)
|
||||
|
||||
```bash
|
||||
# ① 信任表里有几条(标记之间)
|
||||
python3 packaging/sign_manifest.py --check-pinned-keyring
|
||||
|
||||
# ② 就绪度播报(三行:表非空 / 环境里有钥且同源 / 最近一次发布痕迹)
|
||||
python3 packaging/check_signing_ready.py
|
||||
|
||||
# ③ 直接读表
|
||||
sed -n '/PINNED-KEYS/,/PINNED-KEYS/p' crates/core/src/update/trust.rs
|
||||
```
|
||||
|
||||
`check_signing_ready.py` 的 ① 那一行的判据是「标记之间有几条 `PinnedKey`」,**不是**「表是不是
|
||||
空的」——所以哪天有人把标记之间清空,它会立刻喊未就绪(那句文案里的「空表」说的是那一天)。
|
||||
|
||||
## 加/换钥的纪律(没变)
|
||||
|
||||
只能**旧钥签新钥**:那次发布的清单同时带旧钥签名,changelog 点名新钥的 `key_id` 与指纹,
|
||||
然后往 `PINNED-KEYS` 标记之间加一条。步骤全文见 `packaging/docs/signing.md`。
|
||||
|
||||
## 同一轮纠偏的其它两处(M308)
|
||||
|
||||
* `crates/core/src/update/target.rs` 的 `PackageFormat` 文档此前写「portable-zip / appimage /
|
||||
portable-exe 才走自替换」,与 `job::self_replaceable`(只认裸 exe)直接矛盾;现在判据是
|
||||
**便携 exe + 便携归档**两种,两边逐字一致。
|
||||
* Linux/macOS 的 `.tar.gz` 以前「查得到更新、按钮画得出来、点下去必失败」;M308 在
|
||||
`crates/core/src/update/unpack.rs` 里补了「校验通过之后拆出启动器本体」这一跳,于是那两平台
|
||||
的自更新**真的能装**。顺序不变:清单的 `size`/`sha256` 仍然只对**归档字节**做双检。
|
||||
* 界面「重试」按钮现在按 `retryable` 分流:验签类失败不画重试,改说「这次更新已被拒绝
|
||||
(重试没用)」(`Key::UpdateBannerFailedFinal`)。
|
||||
+5
-3
@@ -160,7 +160,8 @@ Git Bash 下会静默落进 Docker VM(脚本内部用 `cygpath -m` 归一)
|
||||
- 验收本体:`cargo test --release -p unlucky-cli --features update-test-source --test update_e2e_test
|
||||
--test update_e2e_tamper_test`(正路 + 幂等 / 篡改摘要 + 篡改包体 + 截断)。CI 里同一个命令挂在
|
||||
`ci.yml` 的 `update-e2e` job(**只在 Windows**:夹具的「真形状」是 Windows 裸 `.exe` +
|
||||
`portable-exe`;Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,在本模块会被直接拒)。
|
||||
`portable-exe`。Linux/macOS 的真实产物是 `.tar.gz` + `portable-zip`,M308 起客户端**会**拆包装
|
||||
本体,但那条路没有 e2e——夹具的资产名与形态按 Windows 的发布走,换平台就是另一条 mission)。
|
||||
**不验**真 GitHub 下载、GUI 点击与重启替换(需人工)。
|
||||
|
||||
## 15. 产物 PE 子系统(M196:GUI 必须 `WINDOWS_GUI`=2)
|
||||
@@ -220,8 +221,9 @@ CUI(3),`grep -rn windows_subsystem crates/` 当时零命中。同一个修复
|
||||
的**原始字节**,不是某个规范化后的 JSON)。信任锚是 `crates/core/src/update/trust.rs` 里的
|
||||
**编译期公钥数组**(keyring);源里没有 `.sig`、钥不在表里、签名对不上,一律 `signature_*` 分类拒装,
|
||||
**不可重试**。签名脚本 `packaging/sign_manifest.py`(零第三方依赖,`--self-test` 跑 RFC 8032 向量)。
|
||||
- **签名就绪度与操作步骤(M255)**:今天 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的** ⇒ 任何正式
|
||||
二进制都验不过任何清单(fail-closed)。读数一条命令:`python3 packaging/check_signing_ready.py`
|
||||
- **签名就绪度与操作步骤(M255)**:`PRODUCTION` 表在 `PINNED-KEYS` 标记之间钉着一条 `ull-release`
|
||||
(commit `68362e5`;M254 审计那句「正式表为空」已作废,见 `docs/update-trust-anchor.md`)。表里一把都
|
||||
没匹配上时仍然拒装(fail-closed)。读数一条命令:`python3 packaging/check_signing_ready.py`
|
||||
(三行 + `0/1/2`,未就绪时打印一行修复命令);CI 里同一条读数在 `signing-status.yml` 的 job summary
|
||||
上,**那是播报不是门禁**(不应设为 required)。要动手做的那 5 步在
|
||||
[`docs/signing-runbook.md`](docs/signing-runbook.md)(不含任何真实密钥)。
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""签名就绪度播报(M255):**只读**、零第三方依赖,三行说清「今天能不能签发一份客户端会认的清单」。
|
||||
"""签名就绪度播报(M255,**M308 纠偏**):**只读**、零第三方依赖,三行说清「今天能不能签发一份客户端会认的清单」。
|
||||
|
||||
为什么要它:M254(CLAIMAUDIT)查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在
|
||||
`PINNED-KEYS` 标记之间是**空的**,而空表一律 `KeyringEmpty` 拒装 —— 于是**今天任何正式二进制都验不过
|
||||
任何清单**(fail-closed 是成立的,但在用户机器上表现为「更新永远失败」)。工具链早就齐了
|
||||
(`sign_manifest.py` 的 `--generate-key` / `--check-pinned-keyring` / `--sign` / `--verify-pinned`),
|
||||
缺的是**操作步骤与可见性**。本脚本只负责「现在差什么」,它不生成钥、不写任何文件、不改 `trust.rs`。
|
||||
为什么要它:M254(CLAIMAUDIT)当时查出 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在
|
||||
`PINNED-KEYS` 标记之间是**空的**,而空表一律 `KeyringEmpty` 拒装 —— 那样任何正式二进制都验不过任何
|
||||
清单(fail-closed 成立,但在用户机器上表现为「更新永远失败」)。**那条读数已经过时**:commit
|
||||
`68362e5` 起 `PRODUCTION` 里钉着一条 `ull-release`,今天的正式构建认这把钥签的清单。本脚本照旧
|
||||
只负责「现在差什么」:它不生成钥、不写任何文件、不改 `trust.rs`;① 那一行的判据也从「是不是空的」
|
||||
变成了「标记之间有几条 PinnedKey」,空表那天它照样会喊未就绪。
|
||||
|
||||
工具链早就齐了(`sign_manifest.py` 的 `--generate-key` / `--check-pinned-keyring` / `--sign` /
|
||||
`--verify-pinned`),缺的从来是**操作步骤与可见性**。
|
||||
|
||||
三行就绪度(各一条判据,互不替代):
|
||||
|
||||
| 行 | 判据 | 未就绪意味着 |
|
||||
|---|---|---|
|
||||
| ① | `PRODUCTION` 表非空:解析 `PINNED-KEYS` 标记之间的 `PinnedKey` 条目,0 条即未就绪 | 客户端一把公钥都没有,任何清单都验不过(今天就是这个状态) |
|
||||
| ① | `PRODUCTION` 表非空:解析 `PINNED-KEYS` 标记之间的 `PinnedKey` 条目,0 条即未就绪 | 客户端一把公钥都没有,任何清单都验不过(**不是**今天的状态:表里已有 `ull-release`;哪天有人把标记之间清空,这一行会立刻喊未就绪) |
|
||||
| ② | 环境里有签名钥:`ULL_MANIFEST_SIGNING_KEY`(或 `--key <文件>`)能解出 32 字节种子,**且**它的公钥在 ① 的表里 | 没钥签不了;钥与信任表不是同一把 ⇒ 发布时 `--check-pinned-keyring` 当场红 |
|
||||
| ③ | 最近一次发布的 `manifest.json` / `.sig` 痕迹 | **不参与就绪判定**。取不到证据就写「未知」——「本地没留档」不等于「上次没签」 |
|
||||
|
||||
@@ -78,7 +82,8 @@ def table_state(path: Path) -> tuple[bool, str, list[tuple[str, str]]]:
|
||||
if keys:
|
||||
names = "、".join(f"{kid}({pk[:16]}…)" for kid, pk in keys)
|
||||
return True, f"就绪 —— 标记之间 {len(keys)} 条:{names}({path}:{line_no} 起)", keys
|
||||
# 空表就是今天的状态。顺手查一个真实易错点:有人把条目贴到标记**外面**(`];` 之后)。
|
||||
# 标记之间 0 条 = 未就绪(M254 那天就是这个状态;今天表里有 `ull-release`,这条分支只会在
|
||||
# 有人清空标记之间的内容时亮)。顺手查一个真实易错点:有人把条目贴到标记**外面**(`];` 之后)。
|
||||
# ⚠ 本仓另有一把 `TEST_ONLY` 测试钥**本来就在标记之外**(那是它的正确位置),所以这里只**点名**,
|
||||
# 不替人下结论 —— 上一版写成「贴错位置,等于没钉」,在真实 trust.rs 上是误报(实测抓出来的)。
|
||||
strays = [kid for kid, _ in KID_RE.findall(text[:begin]) + KID_RE.findall(text[end:])]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""守 `update-test-key`:这把**公开**的测试公钥不许进发布构建(M190 r3)。
|
||||
|
||||
背景:`crates/core` 的信任表(`trust::PRODUCTION`)正式构建里是空的(fail-closed,等用户录钥),
|
||||
背景:`crates/core` 的信任表(`trust::PRODUCTION`)在正式构建里只有**生产钥**(`ull-release`,commit
|
||||
`68362e5` 起;M254 审计那句「正式表为空」已作废,见 `docs/update-trust-anchor.md`),
|
||||
测试公钥 `trust::TEST_ONLY` 只在三档「仅测试」构建里并入:`cfg(test)`、测试通道那条 feature、
|
||||
`update-test-key`。第三档是给 `crates/presenter` 的 `[dev-dependencies]` 用的——presenter 的更新链路
|
||||
用例要构造「本构建认得」的签名清单,而它编译 core 时既没有 `cfg(test)`,也不该打开
|
||||
|
||||
@@ -64,9 +64,12 @@ published 一列,Termux 行标注 experimental)——改一边必须同步
|
||||
`windows-arm64` / `linux-arm64` 的**真实产物只能由 CI 出**(前者靠 `dumpbin -dependents` 的静态 CRT
|
||||
门禁 + 架构校验,后者在 `ubuntu-22.04-arm` 上真跑 `ull --version` 冒烟);本机能做且已做的是脚本层
|
||||
取证(§7.5:宿主判定注入、可执行文件头识别、把真 exe 的 PE 机器码改成 ARM64 当 stub 走完全流程)。
|
||||
8. **Linux 上自更新只"能查不能装"**(M184 发布 Linux x64 之后才有意义):清单现在会带 `linux-x64` 的
|
||||
`portable-zip` 产物,而 `ull update apply` 只做便携单文件 `.exe` 的自替换
|
||||
(`crates/core/src/update/job.rs::ensure_self_replaceable`)——Linux 上它会带具名原因**拒绝**
|
||||
("发布里的 linux-x64 产物是 portable-zip 形态,不能原地替换启动器 … 压缩包请解压后再用"),
|
||||
不会半装坏。Linux 的更新方式仍是「下载新归档覆盖旧副本」。真要做 Linux 自更新(换目录里的文件再重启)
|
||||
是另一件事,未规划。
|
||||
8. **Linux/macOS 自更新:M308 起能装**(M184 发布 Linux x64 之后这条才真正有意义)。清单里
|
||||
`linux-x64` / `mac-*-*` 的产物是 `portable-zip`(真实容器是 `.tar.gz`),M308 之前在
|
||||
`job::ensure_self_replaceable` 处带具名原因**拒绝**;现在 `core::update::unpack` 会在归档字节
|
||||
过完长度/摘要双检之后把里面的启动器本体拆出来,再走同一套原子替换 + 回滚
|
||||
(判据表见 `crates/core/src/update/job.rs::self_replaceable` 与 `unpack.rs` 的模块文档)。
|
||||
三条仍然**不做**的事:①不在检查阶段过滤产物形态(清单挂错形态时是「点下去带原因失败」,
|
||||
不是「装了个坏的」);②不装安装器形态(msi/dmg/deb/rpm 交给系统包管理器);
|
||||
③不认 zip 容器(tar 头里有 `mode`,能客观判「哪个条目是本体」;zip 没有等价约定,宁可拒绝也不猜)。
|
||||
随包文本(LICENSE/NOTICE/README)不随更新落地:更新只换启动器本体,首次安装的归档里那份仍然有效。
|
||||
@@ -1,16 +1,23 @@
|
||||
# 签名就绪度操作手册(M255)
|
||||
|
||||
> **⚠ M308 纠偏(2026-11-13)**:本文写于「信任表是空的」那些日子。commit `68362e5` 起
|
||||
> `trust::PRODUCTION` 在 `PINNED-KEYS` 标记之间钉着一条 `ull-release`,所以 M254 那条
|
||||
> 「任何正式二进制都验不过任何清单」的读数**已作废**(见 `docs/update-trust-anchor.md`)。
|
||||
> 下文凡出现「表空 ⇒ 今天必然红」「信任表是空的」,请读成「**那天**必然红」;步骤本身没变,
|
||||
> 只是第 0 节不再是当下的状态,而 §3 的「一次性录入」已经做过一次了。
|
||||
|
||||
本文件是**人类照做**的手册:从「信任表是空的」走到「发出去的 Release 用客户端判据能验过」。
|
||||
背景/威胁模型/轮换纪律在 [`signing.md`](signing.md);本文件只讲**步骤、命令、坑**。
|
||||
|
||||
> ⚠ 本文件**不含**任何真实私钥、种子或公钥。所有示例都是 `<尖括号占位符>`。
|
||||
> `--generate-key` 的输出是**敏感物**:只在你要贴进 Secret 的那一次运行里存在,别存文件、别贴 issue。
|
||||
|
||||
## 0. 现状读一遍(2026-11 的 M254 审计结论)
|
||||
## 0. 现状读一遍(2026-11 的 M254 审计结论;**已被 M308 取代**)
|
||||
|
||||
`crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是**空的**。空表 =
|
||||
`KeyringEmpty` 一律拒装 ⇒ **今天任何正式二进制都验不过任何清单**(fail-closed 成立,但用户端表现为
|
||||
「更新永远失败」)。已存在的部分(都能用):
|
||||
M254 审计当时读到 `crates/core/src/update/trust.rs` 的 `PRODUCTION` 表在 `PINNED-KEYS` 标记之间是
|
||||
**空的**(空表 = `KeyringEmpty` 一律拒装 ⇒ 用户端表现为「更新永远失败」)。**那条读数已作废**:
|
||||
commit `68362e5` 钉入 `ull-release` 之后,正式构建认得这把钥签的清单。下表「今天」一列也随之为
|
||||
历史(现在 `--check-pinned-keyring` 绿,其余两行取决于环境里有没有配 Secret)。已存在的部分(都能用):
|
||||
|
||||
| 环节 | 在哪 | 今天的状态 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -50,10 +50,14 @@ manifest.json(字节)──签名──> manifest.json.sig
|
||||
|
||||
### 空表 = fail-closed
|
||||
|
||||
正式表当前**是空的**:私钥只能来自仓库 Secret,任何代码仓库都不该自己生成并落盘私钥。
|
||||
空表的效果是「任何正式构建都拒装任何清单」,同时 `--check-pinned-keyring` 让发布流水线当场红。
|
||||
**这是有意的**:宁可不出 Release,也不出未签名的 Release。
|
||||
|
||||
⚠ 正式表**现在不是空的**:commit `68362e5` 钉入了 `ull-release`(M308 纠偏;M254 审计那句
|
||||
「正式表为空」已作废,见 `docs/update-trust-anchor.md`)。上面那条「空表 ⇒ 拒装」仍然成立——
|
||||
它说的是「表里一把都没匹配上」时的行为,不是当下的表内容。私钥只能来自仓库 Secret,
|
||||
任何代码仓库都不该自己生成并落盘私钥(这条没变)。
|
||||
|
||||
## 3. 一次性录入(只做一次)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -45,8 +45,11 @@ MANIFEST_NAME = "manifest.json"
|
||||
CHANNELS = ("stable", "beta", "alpha", "nightly")
|
||||
# 文件名里的平台段 -> 清单 target 的系统段(`windows_x64` -> `win-x64`)。
|
||||
PLATFORM_TARGET = {"windows": "win", "macos": "mac", "linux": "linux"}
|
||||
# 产物扩展名 -> 清单 format。归档(tar.gz)在 DTO 里只有「解压即用」这一档 `portable-zip`:
|
||||
# 名字带 zip,语义是「非安装器、可原地替换」(PackageFormat::needs_installer() == false)。
|
||||
# 产物扩展名 -> 清单 format。归档(tar.gz)在 DTO 里只有 `portable-zip` 这一档:名字带 zip,
|
||||
# 语义是「非安装器、可原地替换」(`PackageFormat::needs_installer() == false`)。
|
||||
# M308 起这条语义是**真的**:`crates/core/src/update/unpack.rs` 会把归档里那个可执行的本体拆出来
|
||||
# 再顶替原文件(此前客户端只认裸 exe,于是 Linux/macOS 的更新点下去必失败)。所以归档里的启动器
|
||||
# 必须带可执行位(`make_artifacts.py` 的 `0o755`),随包文本保持 `0o644`——那是客户端挑本体的判据之一。
|
||||
EXT_FORMAT = {".exe": "portable-exe", ".tar.gz": "portable-zip"}
|
||||
GOLDEN = Path(__file__).resolve().parent / "tests" / "manifest.golden.json"
|
||||
# DTO 侧的取值表镜像(crates/core/src/update/target.rs::parse_os / Arch::parse / PackageFormat::parse)。
|
||||
|
||||
Reference in new issue
Block a user