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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user