fix(m159): r1 复审返工 —— parse.rs 拆出子模块 + 五条 P3 真问题

按独立复审 `reviewer-updategui-r1` 的 p1-1 / fix-then-merge 返工。

P1-1(挡门)
- `crates/cli/src/cli/parse.rs` 406 → **382** 行:`fn parse_update` 搬到
  `crates/cli/src/cli/parse/update.rs`(与 `parse/{accounts,agent,instances}.rs` 同形状)。
  全仓 `crates/**/*.rs` 最大仍是 400(`design/tests/card_neutral.rs` 等三处,未越线)。
  上一轮报告里「所有改动文件都在 400 行内」那句是错的,已在 r2 报告里更正。

P3-6 `Job::apply` 不查包形态
- 新增 `self_replaceable(format)`(只有 `PortableExe` 允许)+ `ensure_self_replaceable`,
  **挡在取包之前**;错误分类 `InvalidManifest`(发布侧挂错,换源/重试无用)。
  比复审建议的 `needs_installer()` 更严:`portable-zip`/`appimage` 同样不能原地当 exe。
- 用例:`crates/core/src/update/tests/job_cases.rs`(msi/zip 被拒 + 目标零改动 + 暂存目录
  没被建 + 便携 exe 整环成功 + Nothing 不碰目标 + 闸门的源码锚点)。

P3-7 `ULL_UPDATE_DIR` 生产可用
- `default_source` 改走 `local_dir_override()`:`if !cfg!(debug_assertions) { return None }`,
  发布构建里整段被常量折叠掉(这条路径在 release 二进制里不存在)。判据与三档取舍写在
  `job.rs` 的文档表里(为什么不用 CLI 参数、为什么不用运行期开关)。

P3-1「忽略这个版本」语义
- `UpdateView.dismissed: bool` → `skipped: Option<String>`(记**版本号**):忽略 0.2.3 之后
  同一次运行里冒出 0.2.4 **照常提示**;手动检查清空(`begin_check(manual)`),静默检查不清。
  文案保持「忽略这个版本」。presenter / app 侧视图模型 / 两处用例同步。

P3-3 CLI `--json` 失败信封缺结构化分类
- `CliError` 加 `detail`(`Option<Box<Value>>`,装箱避免 `result_large_err`)+ `with_detail`,
  `to_json` 输出 `error.detail`;`commands/update.rs` 的 failure 填
  `{"kind": core 分类 id, "retryable": 可重试位}`。
- 上一轮报告里引的 `{"kind":"digest_mismatch"}` 形状确实不存在(那是 CLI 的 user/internal),
  r2 报告换成了实机 dump。

P3-4 update 命令没有解析测试
- `crates/cli/tests/parse_test.rs`:`update_subcommands`(check / apply / `--dest` 两种写法)
  + `update_usage_errors`(无子命令 / 未知子命令 / 多余位置参数 / 缺少取值 / 未知选项);
  `help_text_covers_every_command` 的 topic 列表补 `"update"`。

P3-5 `install.rs` 模块文档与实测矛盾
- 改成「可以改名、不能覆盖/删除」,写明 M159 在 Windows 真机验过运行中的 exe 自替换,
  并提醒别再照旧文档去设计「退出自身 → 另一个进程搬运」的双进程方案。

顺手(复审 P3-2/P3-8 里低成本的两条)
- 设置页补「下载中」态(`SettingsUpdateApplying` 不再是死 key,下载期间不画「下载并安装」);
  渠道显示改读 `update.channel`(`stable` 走 key,其它原样);删掉从未使用的
  `UpdateBannerRestartLater`(catalog + 金表同步,29 条 key 全部有引用)。
- `adopt` 只让**检查**刷新 `checked_at`(应用回报不再冒充「上次检查」);
  `UpdateView::default()` 的 `current_version` 取本进程版本号(mock 下不再空白)。

门禁(`CARGO_TARGET_DIR=C:/Temp/ull-target/wt-159`):fmt 绿、clippy -D warnings 绿、
`cargo test --workspace --all-targets --no-fail-fast -j 4` **exit 0**(51 个测试二进制全 ok,
`core::addon::pack` 未被 skip)。该族用例复现了复审记的泄漏:一次全量在 `/tmp` 留 72 个
`ull-pack-*`(`/tmp` 149M → 8853M),跑完已 `rm -rf` 清回 150M。
This commit is contained in:
AskaEth committed 2026-11-07 13:03:18 +08:00
1 parent 1c8f6aa1b3
commit 04ac90911e
19 files changed
+521 -112

No files matched your search

+8 -2
View File
@@ -20,8 +20,14 @@
//!
//! # 本模块**不**负责的事
//!
//! * **不判断现在能不能替换**。正在跑的 exe 只能由「退出自身 → 由另一个进程搬运」这条
//! 路换掉(HMCL 的 `--apply-to` 模式,见 analyse/07 §4.3)。这里只保证搬一半失败能回滚。
//! * **不判断现在能不能替换**:占用/杀软/权限都在 [`ReplacePolicy`] 的重试里撞,撞不动就回滚。
//! **正在运行的 exe 照样能换**——这一点 M159 在 Windows 真机验过(`ull update apply` 在自己
//! 运行中完成了 `rename(dest→备份)` + `rename(暂存→dest)`,替换后的 exe 仍能启动):
//! Windows 加载器打开映像时带 `FILE_SHARE_DELETE`,所以**可以改名**,只是**不能覆盖/删除**
//! (改字节这条路根本不存在)。这也是本模块「先改名、再搬入」而不是「直接覆盖」的原因。
//! 别照早先那版文档去设计「退出自身 → 由另一个进程搬运」的双进程方案(HMCL 的 `--apply-to`):
//! 那条路在 Windows 上不是必需的。(Unix 上 `rename` 本就可以覆盖,两步写法是为了两边同一条
//! 回滚路径,见下。)
//! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器,
//! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。
//! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再
+69 -7
View File
@@ -38,15 +38,17 @@ use super::checker::{CheckOutcome, UpdateChecker};
use super::error::UpdateError;
use super::github::GitHubReleaseSource;
use super::install::{install_staged, InstallReceipt, ReplacePolicy};
use super::manifest::Artifact;
use super::source::{DirSource, UpdateSource};
use super::target::Target;
use super::target::{PackageFormat, Target};
use super::verify::stage;
use super::version::Version;
/// 本机更新源覆盖(取证 / 内网手工分发):指向一个 `DirSource` 根目录(`<root>/stable.json`)。
///
/// 生产不设它——真实网络下 `releases/latest` 是唯一入口。它的用途是把「发现新版本」这条分支
/// 在没有稳定 Release 的情况下也能整环验一遍(本地清单 + 本地产物,一个字节都不出网)。
/// **只在调试构建里生效**(判据与理由见 [`local_dir_override`]):发布构建忽略它,
/// 真实网络下 `releases/latest` 是唯一入口。它的用途是把「发现新版本」这条分支在没有稳定
/// Release 的情况下也能整环验一遍(本地清单 + 本地产物,一个字节都不出网)。
pub const UPDATE_DIR_ENV: &str = "ULL_UPDATE_DIR";
/// 一次应用的结局。
@@ -165,6 +167,10 @@ impl Job {
let Some(candidate) = outcome.candidate() else {
return Ok(Applied::Nothing(outcome));
};
// P3-6:清单只有 `target` 参与挑选(`Release::artifact_for` 只比平台),所以清单可以给
// win-x64 挂一个 msi;以前这里会把它直接 rename 成本进程的 exe(D50 明说「不做 MSI」)。
// 先按包形态挡一次,**挡在取包之前**:既不浪费下载,也不会在目标路径上留下任何东西。
ensure_self_replaceable(&candidate.artifact)?;
let staged = stage(&*self.source, &candidate.artifact, &self.staging)?;
let version = candidate.release.version.to_string();
match install_staged(&staged, &self.dest, ReplacePolicy::default()) {
@@ -174,6 +180,32 @@ impl Job {
}
}
/// 这个包形态能不能**原地**变成启动器本体。
///
/// 只有「便携单文件 exe」([`PackageFormat::PortableExe`])可以:
///
/// | 形态 | 为什么不行 |
/// |---|---|
/// | `msi` / `dmg` / `deb` / `rpm` | D50 明说「不做 MSI」——那是系统包管理器的活(`needs_installer()`) |
/// | `portable-zip` | 是压缩包,不是启动器本体:rename 到 exe 路径只会得到一个跑不起来的文件 |
/// | `appimage` | Linux 的可执行镜像,Windows/macOS 上没有意义;且本模块只做「同目录改名换 exe」 |
pub(super) fn self_replaceable(format: PackageFormat) -> bool {
matches!(format, PackageFormat::PortableExe)
}
/// 包形态不合规就拒绝(错误分类是 `InvalidManifest`:清单挂错了东西,换源/重试都没用)。
fn ensure_self_replaceable(artifact: &Artifact) -> Result<(), UpdateError> {
if self_replaceable(artifact.format) {
return Ok(());
}
Err(UpdateError::invalid_manifest(format!(
"发布里的 {} 产物是 {} 形态,不能原地替换启动器:本启动器只做便携单文件 exe 的自替换\
(安装器形态请用系统包管理器;压缩包请解压后再用)",
artifact.target.as_str(),
artifact.format.as_str()
)))
}
/// `&dyn UpdateSource` 也能当源用([`UpdateChecker`] 是泛型的,这里省掉一次装箱)。
struct SourceRef<'a>(&'a dyn UpdateSource);
@@ -210,11 +242,41 @@ pub fn staging_dir(cache_root: &Path) -> PathBuf {
cache_root.join("update").join("staging")
}
/// 生产源:GitHub Releases(D50);`ULL_UPDATE_DIR` 设了就走本地目录源。
/// 生产源:GitHub Releases(D50);**只在调试构建里**,`ULL_UPDATE_DIR` 设了就走本地目录源。
#[must_use]
pub fn default_source(engine: Arc<DownloadEngine>) -> Box<dyn UpdateSource> {
match std::env::var(UPDATE_DIR_ENV) {
Ok(root) if !root.trim().is_empty() => Box::new(DirSource::new(root.trim())),
_ => Box::new(GitHubReleaseSource::new(engine)),
match local_dir_override() {
Some(root) => Box::new(DirSource::new(root)),
None => Box::new(GitHubReleaseSource::new(engine)),
}
}
/// `ULL_UPDATE_DIR` 的落点:**只认调试构建**。
///
/// # 为什么判据是 `debug_assertions` 而不是别的
///
/// 这个后门能绕过 GitHub 源:谁能让启动器读到一份本地清单,谁就能让它把任意字节 rename 成本进程
/// 的 exe。清单里的摘要也来自同一份清单,所以**这条路上没有可信根**——它不是权限提升(要能设用户
/// 环境变量 + 用户自己点「下载并安装」),但发布产物里没有任何理由保留它。
///
/// 三档选一,我选了「按构建画像判」:
///
/// | 判据 | 为什么不选 |
/// |---|---|
/// | 命令行显式参数(`--update-dir`) | 要给 GUI 与 CLI 各加一条参数、还要穿到 `Job` 里;而它只服务取证/内网,不该出现在正式用法里 |
/// | 运行期开关(设置键 / 环境变量再加一层鉴权) | 等于「发布版也留着这条路」,只是多了一道可被一起设好的门 |
/// | **`cfg!(debug_assertions)`** | 发布构建(`--release`)里这个常量是 `false`,整段分支被常量折叠掉——**release 二进制里这条路径根本不存在**,而且是编译期事实,不靠用户/环境配合 |
///
/// 调试构建仍认它:本 mission 的「发现新版本 → 下载 → 双检 → 自替换」整环就是用 `cargo build`
/// 出来的二进制 + 本地夹具验的(真实 GitHub 上 v0.2.2 还是 prerelease,取不到 stable 清单)。
fn local_dir_override() -> Option<std::path::PathBuf> {
if !cfg!(debug_assertions) {
return None;
}
let root = std::env::var(UPDATE_DIR_ENV).ok()?;
let root = root.trim();
if root.is_empty() {
return None;
}
Some(std::path::PathBuf::from(root))
}
+159
View File
@@ -0,0 +1,159 @@
//! `Job`(检查/应用的一条龙)的用例:包形态闸门 + 一条成功的自替换。
//!
//! 为什么这层要单独测:`Release::artifact_for` 只比 `target`、不看 `format`,所以「清单给
//! win-x64 挂了一个 msi/zip」是完全可能的输入(复审 P3-6)。以前 `apply` 会把它直接 rename
//! 成本进程的 exe——D50 明说「不做 MSI」,而 zip 当 exe 更是跑不起来。闸门在本模块,
//! 用例就在这里钉住(presenter / CLI 的测试覆盖不到「形态」这一维)。
use crate::platform::os_info::OsFamily;
use crate::update::job::self_replaceable;
use crate::update::{
Applied, Channel, DirSource, Job, PackageFormat, Target, UpdateErrorKind, Version,
};
use super::support::{artifact, manifest, release, TempDir};
const PAYLOAD: &[u8] = b"job payload";
fn target() -> Target {
Target::new(OsFamily::Windows, crate::update::Arch::X64)
}
/// 造一个本地源 + 一个替换目标;返回的 `TempDir` 要活到用例结束。
fn fixture(format: &str, version: &str) -> (TempDir, Job) {
let dir = TempDir::new("job");
dir.write(
"stable.json",
manifest(
"stable",
&[release(
version,
"",
&[artifact("win-x64", format, "asset.bin", PAYLOAD)],
)],
)
.as_bytes(),
);
dir.write("asset.bin", PAYLOAD);
let dest = dir.join("bin/unlucky-launcher.exe");
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).expect("建目标目录");
}
std::fs::write(&dest, b"old-bytes").expect("旧版本");
let staging = dir.join("staging");
let job = Job::new(
Box::new(DirSource::new(dir.path())),
target(),
staging,
dest,
);
(dir, job)
}
/// 只有便携单文件 exe 能原地变成启动器本体。
///
/// 抓得住:把闸门写成 `!needs_installer()`(那样 `portable-zip` 会漏过去,zip 会被 rename 成
/// exe,得到一个跑不起来的启动器)。
#[test]
fn only_portable_exe_is_self_replaceable() {
assert!(self_replaceable(PackageFormat::PortableExe));
for format in [
PackageFormat::Msi,
PackageFormat::Dmg,
PackageFormat::Deb,
PackageFormat::Rpm,
PackageFormat::AppImage,
PackageFormat::PortableZip,
] {
assert!(
!self_replaceable(format),
"{} 不该被当成可原地自替换的形态",
format.as_str()
);
}
}
/// 安装器形态被拒绝,且**目标文件一个字节都没动、暂存目录没建**(闸门在取包之前)。
#[test]
fn an_installer_artifact_is_refused_before_any_download() {
for format in ["msi", "portable-zip"] {
let (dir, job) = fixture(format, "9.9.9");
let error = job
.apply(&Version::parse("0.2.2").expect("版本"), Channel::Stable)
.expect_err("不该装上");
assert_eq!(
error.kind,
UpdateErrorKind::InvalidManifest,
"{format}:清单挂错形态属发布侧问题(换源/重试都没用),实际 {error:?}"
);
assert_eq!(
std::fs::read(job.dest()).expect("读目标"),
b"old-bytes",
"{format}:拒绝之后目标文件不许被动过"
);
assert!(
!job.staging().exists(),
"{format}:闸门在取包之前,暂存目录都不该被建出来"
);
drop(dir);
}
}
/// 便携 exe 形态走完整条路:新字节就位、旧字节进备份。
#[test]
fn a_portable_exe_runs_the_whole_chain() {
let (_dir, job) = fixture("portable-exe", "9.9.9");
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("读目标"), PAYLOAD);
let backup = receipt.backup.expect("旧版本应留备份");
assert_eq!(std::fs::read(backup).expect("读备份"), b"old-bytes");
}
/// 发布里挂的是「没有可装更新」时 `apply` 返回 `Nothing`,不碰任何文件。
#[test]
fn nothing_to_apply_does_not_touch_the_target() {
let dir = TempDir::new("job-none");
dir.write(
"stable.json",
manifest(
"stable",
&[release(
"0.2.2",
"",
&[artifact("win-x64", "portable-exe", "asset.bin", PAYLOAD)],
)],
)
.as_bytes(),
);
let dest = dir.join("bin/unlucky-launcher.exe");
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 crate::update::UpdateSource>,
target(),
dir.join("staging"),
&dest,
);
let applied = job
.apply(&Version::parse("0.2.2").expect("版本"), Channel::Stable)
.expect("不是错误");
assert!(matches!(applied, Applied::Nothing(_)));
assert_eq!(std::fs::read(&dest).expect("读目标"), b"old-bytes");
}
/// 调试构建认 `ULL_UPDATE_DIR`、发布构建不认——判据是靠 `cfg!(debug_assertions)` 折叠掉的,
/// 所以这里用源码锚点把「闸门还在」钉住(release 分支在测试里跑不到,只能这样守)。
#[test]
fn the_local_dir_override_is_debug_only() {
let source = include_str!("../job.rs");
assert!(
source.contains("if !cfg!(debug_assertions) {"),
"`ULL_UPDATE_DIR` 的闸门必须是 debug_assertions(发布构建里整段被折叠掉)"
);
}
+3
View File
@@ -7,6 +7,8 @@
//! * 渠道准入:阶梯比较 + 预发布阶段表;
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
//! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、
//! 「没有可装更新」不碰目标、`ULL_UPDATE_DIR` 只在调试构建里生效;
//! * 更新源:缺清单不是错误、渠道不符报错、远端地址拒绝;
//! * GitHub 源:固定清单地址、产物 URL 解析、非 stable 无更新、与 `DirSource` 对拍。
//!
@@ -16,6 +18,7 @@ mod channel_cases;
mod checker_cases;
mod github_cases;
mod install_cases;
mod job_cases;
mod manifest_cases;
mod source_cases;
mod support;