From 36ff193609e1e525ef53149bf396e1d013e7e129 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 13:06:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(m171):=20=E8=B7=A8=E5=8D=B7=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E6=94=B9=E6=88=90=E3=80=8C=E7=9B=AE=E6=A0=87=E5=90=8C?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=B8=AD=E8=BD=AC=E5=90=8D=20+=20=E5=90=8C?= =?UTF-8?q?=E5=8D=B7=E5=8E=9F=E5=AD=90=E6=94=B9=E5=90=8D=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已发布版本上的真 bug:暂存包在 <数据根>/cache/update/staging,目标 exe 在安装目录, 两者不同卷(便携版放 U 盘 / 数据根在 D 盘而程序在 C 盘 / 部分 macOS 配置)时 `rename(staged → dest)` 直接报 EXDEV / ERROR_NOT_SAME_DEVICE(io::ErrorKind::CrossesDevices), 更新必然失败,而 is_transient 不含 CrossesDevices,也没有任何降级路径。 复现(本机真跨卷:C:/Temp NVMe ↔ D:/Temp 机械盘,原实现): InstallFailure { error: UpdateError { kind: Io, message: "系统无法将文件移到不同的磁盘驱动器。 (os error 17)", path: Some("D:/Temp\ull-xdev-…\app\ull.exe") }, rolled_back: true } 失败点就是 `rename(暂存包 → dest)`;唯一调用方是 core::update::job::Job::apply(CLI/GUI 共用)。 修法(不把原子替换退化成可能留半截的复制): 0. 新包 → <目标同目录>/.<文件名>.ullnew-- 同卷 rename;跨卷才 copy 到该中转名, 并核对写出字节数(少了直接判失败) 1. dest → <同目录 .ullold-…> 旧版本先安全落地 2. rename(中转名 → dest) 同卷,原子 3a. 成功 → InstallReceipt;3b. 失败 → 回滚 + 清中转名 目标路径上的新字节只来自一次原子命名,任何时刻都不是半截文件;同卷路径一个字节都不多搬 (注入用例断言 copies == 0,rename 恰好 3 次)。 备份清理策略一并定死:成功后只留最近一份 .ullold-*(更早的与残留的 .ullnew-* 删掉, 别的程序名的备份不碰)。旧行为是每次 apply 留一份、全仓无人调 remove_backup ⇒ 无限堆积。 顺手把文件动作抽成 replace_io::ReplaceFs(真实现 = std::fs),让「跨卷被拒」「复制到一半 失败」「改名失败」成为确定性输入;判据与顺序仍在 install 里,注入改不了。 守卫(都做过变异实测,均能变红): * install_xdev_cases:真跨卷安装成功且目标字节完整(去掉降级 → 红,报 os error 17) * install_io_cases:跨卷降级只复制一次 / 半截复制后目标仍是旧字节且无 .ullnew- 残留 / 短复制被字节数判据拒掉 / 最后一步改名失败回滚并清中转名 / 同卷 copies == 0 * cli update_e2e_test::installs_across_volumes_from_the_cli:数据根一卷、安装目录另一卷, 整环从 CLI 入口跑通(去掉降级 → 红,输出 update_apply_failed + os error 17) --- crates/cli/tests/support/mod.rs | 84 +++++- crates/cli/tests/support/update_e2e.rs | 14 +- crates/cli/tests/update_e2e_test.rs | 79 ++++- crates/core/src/update/install.rs | 255 ++++++++++------ crates/core/src/update/mod.rs | 12 +- crates/core/src/update/replace_io.rs | 129 +++++++++ crates/core/src/update/tests/install_cases.rs | 59 ++-- .../core/src/update/tests/install_io_cases.rs | 271 ++++++++++++++++++ .../src/update/tests/install_xdev_cases.rs | 105 +++++++ crates/core/src/update/tests/mod.rs | 6 +- crates/core/src/update/tests/support.rs | 112 ++++++++ 11 files changed, 1007 insertions(+), 119 deletions(-) create mode 100644 crates/core/src/update/replace_io.rs create mode 100644 crates/core/src/update/tests/install_io_cases.rs create mode 100644 crates/core/src/update/tests/install_xdev_cases.rs diff --git a/crates/cli/tests/support/mod.rs b/crates/cli/tests/support/mod.rs index bee4760..08a2c1d 100644 --- a/crates/cli/tests/support/mod.rs +++ b/crates/cli/tests/support/mod.rs @@ -17,17 +17,26 @@ pub struct TempDir { impl TempDir { /// 建一个唯一目录。 pub fn new(tag: &str) -> Self { + Self::in_root(&std::env::temp_dir(), tag).expect("创建临时目录") + } + + /// 建在**另一块卷**上(跨卷验收用);本机只有一块可写卷时返回 `None`。 + pub fn on_other_volume(tag: &str) -> Option { + other_volume_root().and_then(|root| Self::in_root(&root, tag)) + } + + fn in_root(root: &Path, tag: &str) -> Option { static COUNTER: AtomicU64 = AtomicU64::new(0); let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |duration| duration.as_nanos()); let seq = COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir().join(format!( + let path = root.join(format!( "ull-cli-test-{tag}-{}-{seq}-{nanos}", std::process::id() )); - std::fs::create_dir_all(&path).expect("创建临时目录"); - Self { path } + std::fs::create_dir_all(&path).ok()?; + Some(Self { path }) } /// 目录路径。 @@ -47,6 +56,75 @@ impl Drop for TempDir { } } +/// 指定异卷根目录的环境变量(本机找不到第二块卷时,用它显式指一块)。 +pub const XDEV_ROOT_ENV: &str = "ULL_XDEV_TEST_ROOT"; + +/// 一块与会话临时目录**真的**不同卷的可写根目录(探出来的,不是按平台猜的)。 +/// +/// 候选:`ULL_XDEV_TEST_ROOT` → Windows 各盘符的 `Temp`(没有就盘根)/ Unix 的 `TMPDIR`、 +/// `/dev/shm`、`/tmp`、`/run/user/`。每个候选都要过「从 temp 目录 rename 进它必须报 +/// `CrossesDevices`」这一关,所以返回值**一定**是异卷——同一块卷上的候选会被跳过, +/// 否则跨卷用例会在同卷上跑,等于什么都没验。 +pub fn other_volume_root() -> Option { + let mut candidates = Vec::new(); + if let Some(root) = std::env::var_os(XDEV_ROOT_ENV) { + candidates.push(PathBuf::from(root)); + } + candidates.extend(volume_candidates()); + candidates + .into_iter() + .find(|candidate| is_other_volume(candidate)) +} + +fn volume_candidates() -> Vec { + #[cfg(windows)] + { + ('A'..='Z') + .map(|letter| PathBuf::from(format!("{letter}:/"))) + .filter(|root| root.is_dir()) + .map(|root| { + if root.join("Temp").is_dir() { + root.join("Temp") + } else { + root + } + }) + .collect() + } + #[cfg(not(windows))] + { + let mut candidates: Vec = ["TMPDIR", "TMP", "TEMP"] + .iter() + .filter_map(|key| std::env::var_os(key).map(PathBuf::from)) + .collect(); + for root in ["/dev/shm", "/tmp", "/var/tmp"] { + candidates.push(PathBuf::from(root)); + } + if let Some(uid) = std::env::var_os("UID") { + candidates.push(PathBuf::from("/run/user").join(uid)); + } + candidates + } +} + +fn is_other_volume(dir: &Path) -> bool { + if dir.as_os_str().is_empty() || std::fs::create_dir_all(dir).is_err() { + return false; + } + let probe = std::env::temp_dir().join(format!("ull-cli-probe-{}", std::process::id())); + if std::fs::write(&probe, b"probe").is_err() { + return false; + } + let landed = dir.join(format!("ull-cli-probe-{}", std::process::id())); + let cross = matches!( + std::fs::rename(&probe, &landed), + Err(ref error) if error.kind() == std::io::ErrorKind::CrossesDevices + ); + let _ = std::fs::remove_file(&probe); + let _ = std::fs::remove_file(&landed); + cross +} + /// 跑一次 CLI,返回(退出码, stdout, stderr)。 pub fn run_cli(args: &[&str]) -> (i32, String, String) { let owned: Vec = args.iter().map(|arg| arg.to_string()).collect(); diff --git a/crates/cli/tests/support/update_e2e.rs b/crates/cli/tests/support/update_e2e.rs index ddd73f1..d3fa42f 100644 --- a/crates/cli/tests/support/update_e2e.rs +++ b/crates/cli/tests/support/update_e2e.rs @@ -222,9 +222,21 @@ fn payload_bytes() -> Vec { impl Fixture { pub fn new(tag: &str) -> Self { + Self::with_install(tag, super::TempDir::new(&format!("{tag}-install"))) + } + + /// 安装目录在**另一块卷**上的现场(数据根与暂存仍在会话 temp):M171 的真机跨卷形态。 + /// + /// 找不到第二块可写卷时返回 `None`(调用方跳过并说明原因)——这条用例在只有一块卷的 + /// 机器/CI 上**不生效**,跨卷逻辑本身由 `crates/core` 的注入用例守着。 + pub fn cross_volume(tag: &str) -> Option { + let install = super::TempDir::on_other_volume(&format!("{tag}-install"))?; + Some(Self::with_install(tag, install)) + } + + fn with_install(tag: &str, install: super::TempDir) -> Self { let payload = payload_bytes(); let version = next_version(env!("CARGO_PKG_VERSION")); - let install = super::TempDir::new(&format!("{tag}-install")); let dest = install.path().join("unlucky-launcher.exe"); std::fs::write(&dest, INSTALLED).expect("写旧版本"); Self { diff --git a/crates/cli/tests/update_e2e_test.rs b/crates/cli/tests/update_e2e_test.rs index b0c28db..158cd5a 100644 --- a/crates/cli/tests/update_e2e_test.rs +++ b/crates/cli/tests/update_e2e_test.rs @@ -33,8 +33,9 @@ //! | 清单 → 选版本 → 下载 → 双检 → 就位整环跑通 | 任一环断了(字段拼错、渠道不符、没下、没校验、没搬)都会红 | 真 GitHub Release 的下载链路(本套一个字节不出网) | //! | 清单 `sha256` 与盘上字节不符 ⇒ 拒绝 + 目标不动 | 「校验和判据被绕过/写成恒真」;篡改包体或篡改清单都能抓(见 `update_e2e_tamper_test`) | 「CDN 换了内容**而清单里的 sha 也一起换**」——这条路上没有可信根,只能靠 GitHub 侧 | //! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 | -//! | 重复 `apply` 后目标字节稳定、暂存无残留 | 「第二次把 dest 写坏 / 暂存留下垃圾」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe | -//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷(数据根与 exe 不同盘)时的替换:`rename` 不能跨卷,那条路没验 | +//! | 重复 `apply` 后目标字节稳定、暂存无残留、只留一份备份 | 「第二次把 dest 写坏 / 暂存留下垃圾 / 备份无限堆积」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe | +//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷失败路径:本文件只验了跨卷**成功**那一半(见下一行);跨卷下的中途失败由 `install_io_cases` 注入验 | +//! | 跨卷 `apply`(数据根一卷、安装目录另一卷,M171) | 「rename 不能跨卷 ⇒ 更新必然失败」这条已发布版本上的真 bug;用例自己先证明前提(rename 报 `CrossesDevices`) | 只有一块可写卷的机器/CI 上**跳过**(明文打印);macOS 的跨卷行为一台没跑过 | //! | 失败路径三例(`update_e2e_tamper_test`:篡改清单 sha / 篡改包体 / 截断) | 「校验判据被绕过」「失败却已动过目标」「失败留下 `.ullupd`」 | **回滚与备份**:摘要/长度失败发生在 `install_staged` **之前**,那一步根本走不到——回滚靠正路的备份断言 + `install_cases` 单测(r1 复审 ⑤ 的变异实测:去掉 dest 备份后失败路径三例仍全绿,只有正路/幂等变红) | //! //! # 不验的部分(如实划界) @@ -47,6 +48,9 @@ //! (`update_session_tests.rs`),CLI 侧没有对应开关,所以这里不覆盖。 //! 4. **`--dest` 之外的自替换**(替换本进程 exe):`tests/support/update_e2e.rs` 的文件头写了为什么 //! 这几条用例永远带 `--dest`,以及「去掉它会真的改名换掉测试二进制」这件事。 +//! 5. **macOS / Linux 上的跨卷**:跨卷用例只在**本机 Windows**(C: NVMe + D: 机械盘)上真跑过; +//! macOS 的卷(APFS 宗卷之间 `/Volumes` 挂载)与 Linux 的 tmpfs↔ext4 一次都没跑——两边的 +//! `EXDEV` 语义相同,但「同目录中转 + 改名」在同一台机器上有没有别的坑,本套没有证据。 #![cfg(feature = "update-test-source")] @@ -160,10 +164,11 @@ fn an_empty_local_channel_is_up_to_date_not_an_error() { assert_eq!(data["test_source"], true); } -/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾。 +/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾、备份只有一份。 /// -/// 如实记两条:`apply` 每次都留一个备份(CLI 从不删,等下一次启动/人工清理),而且第二次之所以 -/// 还会「装上」,是因为 `current` 是编译期版本——重复执行不是空操作,只是结果幂等。 +/// 如实记两条:`apply` 每次都留一份备份(CLI 从不删,等下一次启动/人工清理),但**只留最近一份** +/// (`install::prune_stale_backups`,M171);第二次之所以还会「装上」,是因为 `current` 是编译期 +/// 版本——重复执行不是空操作,只是结果幂等。 #[test] fn applying_twice_leaves_the_same_target_bytes() { let _serial = channel_lock(); @@ -189,8 +194,68 @@ fn applying_twice_leaves_the_same_target_bytes() { } assert_eq!( fx.backups().len(), - 2, - "每次 apply 留一个备份(CLI 不删):{:?}", + 1, + "只留最近一份备份(每次 apply 建一份、成功即清更早的):{:?}", fx.backups() ); } + +/// 跨卷(M171):数据根在一卷、安装目录在另一卷时,整环从 CLI 入口仍然跑通。 +/// +/// 这正是已发布版本上必然失败的那个场景(便携版放 U 盘、数据根在 D 盘而程序在 C 盘): +/// `stage()` 把包落在 `<数据根>/cache/update/staging`,`install_staged()` 要把包搬进 exe 目录, +/// 而 `rename` 不能跨卷。修法是先落到**目标同目录**的中转名(同卷改名)再顶上去。 +/// +/// 用例先自己证明**前提成立**(从数据根到安装目录 rename 必须报 `CrossesDevices`),否则 +/// 「跨卷成功」这句话就是假的。本机只有一块可写卷时**跳过**(明文打印原因)——跨卷逻辑本身由 +/// `crates/core` 的 `install_xdev_cases`(真跨卷)与 `install_io_cases`(注入)守着。 +#[test] +fn installs_across_volumes_from_the_cli() { + let _serial = channel_lock(); + let _restore = Channel::capture(); + let Some(fx) = Fixture::cross_volume("e2e-xdev") else { + eprintln!( + "跳过跨卷 e2e:本机找不到第二块可写卷(可用 ULL_XDEV_TEST_ROOT 指一块,\ + 例如 C:/Temp + D:/Temp)" + ); + return; + }; + Channel::enable(fx.dist.path()); + fx.publish_good(); + + // 前提:数据根(暂存所在地)与安装目录**真的**不同卷。 + let install_dir = fx.dest.parent().expect("安装目录").to_path_buf(); + let probe = fx.data.path().join("volume-probe.bin"); + std::fs::write(&probe, b"probe").expect("写探针"); + let cross = std::fs::rename(&probe, install_dir.join("volume-probe.bin")) + .expect_err("跨卷 rename 必须失败"); + assert_eq!( + cross.kind(), + std::io::ErrorKind::CrossesDevices, + "前提不成立:数据根与安装目录同卷({cross})" + ); + + let (code, json) = fx.apply(); + assert_eq!(code, 0, "跨卷 apply 应成功:{json}"); + let data = support::update_e2e::payload(&json); + assert_eq!(data["status"], "installed", "{json}"); + assert_eq!(data["path"], fx.dest.display().to_string().as_str()); + assert_eq!( + fx.installed_bytes(), + fx.payload, + "装上去的必须是清单里那一份完整字节(不是半截)" + ); + assert!( + fx.staging_leftovers().is_empty(), + "暂存目录里不许留 .ullupd:{:?}", + fx.staging_leftovers() + ); + let backups = fx.backups(); + assert_eq!(backups.len(), 1, "旧版本要留一份备份:{backups:?}"); + let backup = install_dir.join(&backups[0]); + assert_eq!( + std::fs::read(&backup).expect("读备份"), + INSTALLED, + "旧字节必须完整留在备份里" + ); +} diff --git a/crates/core/src/update/install.rs b/crates/core/src/update/install.rs index 9dbc0fd..4d6de20 100644 --- a/crates/core/src/update/install.rs +++ b/crates/core/src/update/install.rs @@ -1,4 +1,5 @@ -//! 原子替换:先把旧文件改名挪开,再把新文件搬进来;任一步失败都能回滚。 +//! 原子替换:先把新包落到**目标同目录**的中转名,再把旧文件改名挪开、中转名顶上; +//! 任一步失败都能回滚,**目标路径上永远不会出现半截文件**。 //! //! # 为什么不用「直接覆盖」 //! @@ -8,16 +9,38 @@ //! 把旧文件挪到同目录的备份名,让新文件顶替原来的路径。而改名本身也可能被拒绝(杀软扫描 //! 句柄、编辑器打开、另一个进程正在写),所以失败必须能回到旧版本——这正是本模块存在的理由。 //! -//! 三步写法,Unix 上也照这个顺序跑(Unix 本可直接 `rename` 覆盖,但两套语义分叉之后, -//! 回滚路径只有 Windows 被真正跑过,最容易坏的那一半就没人测了): +//! # 为什么先落中转名(M171 跨卷修复) +//! +//! 暂存包在 `<数据根>/cache/update/staging`,目标 exe 在安装目录:**两者可能不在同一卷** +//! (便携版放 U 盘、数据根在 D 盘而程序在 C 盘、部分 macOS 配置)。`rename` 不能跨卷 +//! (Unix `EXDEV` / Windows `ERROR_NOT_SAME_DEVICE`,`std` 不会偷偷降级成复制), +//! 于是「暂存包 → 目标」那一步必然失败——M169 复审判定这是已发布版本上的真实 bug。 +//! +//! 修法不是「把 rename 换成 copy」(那会让目标路径上出现半截文件),而是把**原子替换的前提** +//! 变成不依赖跨卷: //! //! ```text -//! 1. dest 存在 → rename(dest → <同目录备份>) 旧版本先安全落地 -//! 2. rename(暂存包 → dest) 新版本就位 +//! 0. 新包 → <目标同目录>/.<文件名>.ullnew-- 同卷改名;跨卷时复制到这儿并核对字节数 +//! 1. dest 存在 → rename(dest → <同目录 .ullold-…>) 旧版本先安全落地 +//! 2. rename(中转名 → dest) 新版本就位(同卷,原子) //! 3a. 成功 → 返回 InstallReceipt(备份留着,等新版真的能起来再删) //! 3b. 失败 → rename(备份 → dest) 回滚,并把「回滚是否成功」如实报给调用方 //! ``` //! +//! 关键性质:**第 2 步之后目标路径要么是旧字节要么是新字节**,不可能是一半。跨卷那条路多出来的 +//! 复制只发生在第 0 步,落的是中转名:复制到一半失败 → 删中转名、目标一个字节都没动 +//! (多数情况下备份都还没建)。同一卷上走的是改名不是复制,**没有一个字节被多搬一次**。 +//! +//! Unix 上也照这个顺序跑(Unix 本可直接 `rename` 覆盖,但两套语义分叉之后,回滚路径只有 Windows +//! 被真正跑过,最容易坏的那一半就没人测了)。 +//! +//! # 旧版本备份留几份 +//! +//! 替换成功的那一刻会把**同目录里更早的** `.<同名文件>.ullold-*` 删掉,只留本次这一份 +//! (见 `prune_stale_artifacts`):旧实现在每次 apply 都留一份、而全仓**没有任何调用方**调 +//! [`InstallReceipt::remove_backup`],等于每更新一次就在安装目录里堆一个 20+ MB 的 exe。 +//! 「留多份」唯一能多给的东西是「回退到更早的版本」,而回滚只用最近一份,所以按份数上界收敛成 1。 +//! //! # 本模块**不**负责的事 //! //! * **不判断现在能不能替换**:占用/杀软/权限都在 [`ReplacePolicy`] 的重试里撞,撞不动就回滚。 @@ -27,10 +50,10 @@ //! (改字节这条路根本不存在)。这也是本模块「先改名、再搬入」而不是「直接覆盖」的原因。 //! 别照早先那版文档去设计「退出自身 → 由另一个进程搬运」的双进程方案(HMCL 的 `--apply-to`): //! 那条路在 Windows 上不是必需的。(Unix 上 `rename` 本就可以覆盖,两步写法是为了两边同一条 -//! 回滚路径,见下。) +//! 回滚路径,见上。) //! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器, //! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。 -//! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再 +//! * **不重启、不删备份**(最新一份何时删由上层决定:新版启动成功后 //! [`InstallReceipt::remove_backup`])。 //! //! # 单测覆盖到哪 @@ -40,51 +63,31 @@ //! 让 `rename` 撞共享冲突,于是在 Windows 上能看到「重试 → 仍失败 → 旧版本完好」。 //! 那条用例是 `#[cfg(windows)]`:Unix 允许改名打开中的文件,本来就该成功。 //! -//! 但它不是「运行中的 exe」——那需要真的跑一个子进程并替换它,属真机验收,不在单测范围。 +//! 跨卷场景是**真的跨卷**(`install_xdev_cases` 拿一次真实 rename 探出第二块卷),真盘上没法 +//! 确定性复现的「跨卷被拒」「复制/改名到一半失败」则由 `install_io_cases` 的注入器打出来。 +//! 它都不是「运行中的 exe」——那需要真的跑一个子进程并替换它,属真机验收,不在单测范围。 //! 另外注意:`std::fs::File::open` **默认共享删除**,用它造的「占用」不生效, //! 写这类用例时必须显式指定 `share_mode`。 use std::fmt; use std::fs; -use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; use super::error::UpdateError; +use super::replace_io::{is_cross_device, rename_with_retry, RealFs, ReplaceFs}; use super::verify::StagedPackage; -/// 同进程内备份文件名去重序号。 +pub use super::replace_io::ReplacePolicy; + +/// 同进程内备份 / 中转文件名去重序号。 static SEQ: AtomicU64 = AtomicU64::new(0); -/// 替换策略:改名失败时重试几次、每次间隔多久。 -/// -/// 默认 5 次 × 80ms:足够扛过杀软扫描/索引器短暂占用,又不至于让用户在「更新」按钮上 -/// 干等半分钟。真正长期的占用(另一个启动器实例在跑)重试多少次都没用,直接报错更诚实。 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ReplacePolicy { - /// 总尝试次数(含首次),`0` 按 `1` 处理。 - pub attempts: u32, - /// 两次尝试之间的等待。 - pub delay: Duration, -} +/// 旧版本备份的名字标签(`.<文件名>.ullold--`)。 +const OLD_TAG: &str = "ullold"; -impl Default for ReplacePolicy { - fn default() -> Self { - Self { - attempts: 5, - delay: Duration::from_millis(80), - } - } -} - -impl ReplacePolicy { - /// 构造。 - #[must_use] - pub const fn new(attempts: u32, delay: Duration) -> Self { - Self { attempts, delay } - } -} +/// 新版本中转名的名字标签(`.<文件名>.ullnew--`)。 +const NEW_TAG: &str = "ullnew"; /// 替换成功后的收据。 #[derive(Debug, Clone, PartialEq, Eq)] @@ -121,7 +124,7 @@ impl InstallReceipt { let Some(backup) = self.backup.as_ref() else { return Ok(()); }; - rename_with_retry(backup, &self.dest, policy) + rename_with_retry(&RealFs, backup, &self.dest, policy) .map_err(|error| UpdateError::io_at(&self.dest, &error))?; self.backup = None; Ok(()) @@ -167,12 +170,24 @@ impl std::error::Error for InstallFailure {} /// /// # Errors /// -/// 目录建不出来、目标被占用、改名失败都返回 [`InstallFailure`], +/// 目录建不出来、目标被占用、改名/跨卷复制失败都返回 [`InstallFailure`], /// 其中的 `rolled_back` 如实反映旧版本有没有回到原位。 pub fn install_staged( staged: &StagedPackage, dest: &Path, policy: ReplacePolicy, +) -> Result { + install_with(&RealFs, staged, dest, policy) +} + +/// [`install_staged`] 的注入版:文件动作用调用方给的 [`ReplaceFs`]。 +/// +/// 判据与顺序与本函数完全相同——测试只能改「系统调用成不成功」,改不了「失败时目标是否完好」。 +pub(crate) fn install_with( + fs: &dyn ReplaceFs, + staged: &StagedPackage, + dest: &Path, + policy: ReplacePolicy, ) -> Result { if let Some(parent) = dest .parent() @@ -182,28 +197,44 @@ pub fn install_staged( .map_err(|error| failed(UpdateError::io_at(parent, &error), true, None))?; } + // 第 0 步:新包 → 目标同目录的中转名。**先做这一步**:它在目标没被碰过的情况下把 + // 「搬不动」暴露出来(跨卷、临时目录写不进),失败时连备份都不用建。 + let new = new_path(dest); + if let Err(error) = materialize(fs, staged, &new, policy) { + let _ = fs.remove(&new); + return Err(failed(UpdateError::io_at(dest, &error), true, None)); + } + + // 第 1 步:dest → 同目录备份。 let mut backup = None; if fs::symlink_metadata(dest).is_ok() { let path = backup_path(dest); - rename_with_retry(dest, &path, policy) - .map_err(|error| failed(UpdateError::io_at(dest, &error), true, None))?; + if let Err(error) = rename_with_retry(fs, dest, &path, policy) { + let _ = fs.remove(&new); + return Err(failed(UpdateError::io_at(dest, &error), true, None)); + } backup = Some(path); } - match rename_with_retry(staged.path(), dest, policy) { - Ok(()) => Ok(InstallReceipt { - dest: dest.to_path_buf(), - backup, - bytes: staged.size(), - }), + // 第 2 步:中转名 → dest(同卷,原子)。 + match rename_with_retry(fs, &new, dest, policy) { + Ok(()) => { + prune_stale_artifacts(backup.as_deref(), dest); + Ok(InstallReceipt { + dest: dest.to_path_buf(), + backup, + bytes: staged.size(), + }) + } Err(error) => { + let _ = fs.remove(&new); let error = UpdateError::io_at(dest, &error); let Some(backup_path) = backup else { // 目标原本不存在:没有旧版本要恢复,目标也没被写坏, // 所以按「旧版本仍在原位」上报(新安装失败 ≠ 现有安装被破坏)。 return Err(failed(error, true, None)); }; - match rename_with_retry(&backup_path, dest, policy) { + match rename_with_retry(fs, &backup_path, dest, policy) { Ok(()) => Err(failed(error, true, None)), Err(_) => Err(failed(error, false, Some(backup_path))), } @@ -211,6 +242,41 @@ pub fn install_staged( } } +/// 第 0 步:把暂存包落成 `new`(目标同目录)。同卷改名一步到位;跨卷才复制,并核对字节数。 +fn materialize( + fs: &dyn ReplaceFs, + staged: &StagedPackage, + new: &Path, + policy: ReplacePolicy, +) -> std::io::Result<()> { + match rename_with_retry(fs, staged.path(), new, policy) { + Ok(()) => Ok(()), + // 跨卷不重试:换多少次都不会变成同卷。复制是**唯一**的降级方向,而且只能往 + // 目标同目录的中转名里复制——绝不许直接往 dest 上复制。 + Err(error) if is_cross_device(&error) => copy_across_volumes(fs, staged, new), + Err(error) => Err(error), + } +} + +/// 跨卷降级:复制到中转名,并核对写出的字节数(少了就是半截,直接判失败)。 +/// +/// 复制**不重试**:它不是占用问题,重来一次只会再搬几十 MB。失败后留下的半截文件由 +/// `install_with` 删掉(`fs::copy` 自己不会收拾)。 +fn copy_across_volumes( + fs: &dyn ReplaceFs, + staged: &StagedPackage, + new: &Path, +) -> std::io::Result<()> { + let copied = fs.copy(staged.path(), new)?; + if copied != staged.size() { + return Err(std::io::Error::other(format!( + "跨卷复制只写出 {copied} 字节,暂存包是 {} 字节(拒绝把它顶到目标上)", + staged.size() + ))); + } + Ok(()) +} + fn failed(error: UpdateError, rolled_back: bool, backup_left: Option) -> InstallFailure { InstallFailure { error, @@ -219,6 +285,19 @@ fn failed(error: UpdateError, rolled_back: bool, backup_left: Option) - } } +fn tagged_path(dest: &Path, tag: &str) -> PathBuf { + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + let name = dest.file_name().map_or_else( + || String::from("ull"), + |name| name.to_string_lossy().into_owned(), + ); + let tagged = format!(".{name}.{tag}-{}-{seq}", std::process::id()); + match dest.parent() { + Some(parent) => parent.join(tagged), + None => PathBuf::from(tagged), + } +} + /// 旧版本的备份路径:`/.<文件名>.ullold--`。 /// /// 点号开头在 Unix 上是隐藏文件,也和 `download` 的 `.ullpart-*` 中间文件一个家族; @@ -227,48 +306,46 @@ fn failed(error: UpdateError, rolled_back: bool, backup_left: Option) - /// 不会偷偷降级成复制)。 #[must_use] pub fn backup_path(dest: &Path) -> PathBuf { - let seq = SEQ.fetch_add(1, Ordering::Relaxed); - let name = dest.file_name().map_or_else( - || String::from("ull"), - |name| name.to_string_lossy().into_owned(), - ); - let backup = format!(".{name}.ullold-{}-{seq}", std::process::id()); - match dest.parent() { - Some(parent) => parent.join(backup), - None => PathBuf::from(backup), - } + tagged_path(dest, OLD_TAG) } -/// 改名,失败时按策略重试(占用类错误才值得重试)。 -fn rename_with_retry(from: &Path, to: &Path, policy: ReplacePolicy) -> io::Result<()> { - let attempts = policy.attempts.max(1); - let mut last = None; - for attempt in 0..attempts { - match fs::rename(from, to) { - Ok(()) => return Ok(()), - Err(error) => { - let retryable = is_transient(&error) && attempt + 1 < attempts; - if !retryable { - return Err(error); - } - std::thread::sleep(policy.delay); - last = Some(error); - } +/// 新版本的中转名:`/.<文件名>.ullnew--`。 +/// +/// **必须与 dest 同目录**:这是「不经复制就把新字节原子地放到目标位置上」的全部前提。 +fn new_path(dest: &Path) -> PathBuf { + tagged_path(dest, NEW_TAG) +} + +/// 只留最近一份旧版本备份:删掉同目录里更早的 `..ullold-*` 与残留的 `.ullnew-*`。 +/// +/// 删失败不当错误(只是垃圾文件,下次 apply 再清);只在替换成功之后调用,所以正在用的 +/// 那一份(`keep`)不会被动。`.ullnew-*` 全是垃圾:本次那份在调用前已经被改名成 dest 了, +/// 还留着的只可能是更早某次失败遗留的中转文件(本次失败路径自己会删,删不掉才会留到这里)。 +/// +/// 前提是「同一个 exe 只有一个更新者在动」。两个进程同时替换同一路径本来就不成立: +/// 后一个回滚会把前一个刚装上的新版本换成旧字节。这条不因为保留多份而变好,反倒因为 +/// 每份都是几十 MB 的 exe 而变坏。 +fn prune_stale_artifacts(keep: Option<&Path>, dest: &Path) { + let (Some(dir), Some(name)) = (dest.parent(), dest.file_name()) else { + return; + }; + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + let name = name.to_string_lossy(); + let old_prefix = format!(".{name}.{OLD_TAG}-"); + let new_prefix = format!(".{name}.{NEW_TAG}-"); + for entry in entries.flatten() { + let path = entry.path(); + if Some(path.as_path()) == keep { + continue; + } + let stale = path.file_name().is_some_and(|other| { + let other = other.to_string_lossy(); + other.starts_with(&old_prefix) || other.starts_with(&new_prefix) + }); + if stale { + let _ = fs::remove_file(&path); } } - Err(last.unwrap_or_else(|| io::Error::other("rename 未执行"))) -} - -/// 是否属于「等一会儿可能就好了」的失败。 -/// -/// Windows 的共享冲突(`os error 32`)落在 `ErrorKind::Other` 里(`std` 没有专门的 kind), -/// 所以这里必须把 `Other` 也算上;`PermissionDenied` 覆盖 `ACCESS_DENIED`(杀软持有的句柄)。 -fn is_transient(error: &io::Error) -> bool { - matches!( - error.kind(), - io::ErrorKind::PermissionDenied - | io::ErrorKind::Other - | io::ErrorKind::Interrupted - | io::ErrorKind::WouldBlock - ) } diff --git a/crates/core/src/update/mod.rs b/crates/core/src/update/mod.rs index 6fee236..ce4db7c 100644 --- a/crates/core/src/update/mod.rs +++ b/crates/core/src/update/mod.rs @@ -30,10 +30,17 @@ //! manifest 清单模型 + JSON 校验 target 目标平台与包形态 //! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50) //! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急 -//! install 原子替换 + 回滚 error 分类与文案 -//! job 检查/应用的一条龙(GUI 与 CLI 共用同一入口) +//! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点) +//! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口) //! ``` //! +//! # 跨卷(M171) +//! +//! 暂存目录在数据根下、目标 exe 在安装目录,两者可能不在同一卷(便携版放 U 盘、数据根在 D 盘而 +//! 程序在 C 盘)。`rename` 不能跨卷,所以 [`install_staged`] 先把新包落成**目标同目录**的 +//! `.ullnew-*` 中转名(同卷改名;跨卷才复制到这儿并核对字节数),再由一次同卷改名顶到目标位置。 +//! 目标路径上任何时刻都不是半截文件,同一卷路径也**不会多搬一个字节**。 +//! //! # 刻意不做的事 //! //! * **不碰 UI**:本模块只产出可序列化的判定结果与错误分类,弹窗/进度/文案排版在壳层。 @@ -50,6 +57,7 @@ pub mod github; pub mod install; pub mod job; pub mod manifest; +mod replace_io; pub mod source; pub mod target; pub mod verify; diff --git a/crates/core/src/update/replace_io.rs b/crates/core/src/update/replace_io.rs new file mode 100644 index 0000000..52d09da --- /dev/null +++ b/crates/core/src/update/replace_io.rs @@ -0,0 +1,129 @@ +//! 替换过程真正碰盘的三个动作(改名 / 复制 / 删除)+ 重试与跨卷判据。 +//! +//! # 为什么抽成 trait +//! +//! 只为一件事:**故障注入**。两条最关键的坏路径在真盘上没法确定性复现—— +//! +//! * 「跨卷 rename 被拒」(`EXDEV` / `ERROR_NOT_SAME_DEVICE`):得先有第二块卷; +//! * 「复制/改名到一半失败」:得让盘真的满,或者让杀软恰好在那 100ms 里咬住文件。 +//! +//! 真实现 [`RealFs`] 就是 `std::fs` 的三个函数(薄到没有分支);测试用注入器把上面两种 +//! 失败变成 `install_with` 上的确定性输入。抽的只有「动作」,**判据与顺序仍在 `install` 里**, +//! 所以注入器能改的只是「系统调用成不成功」,改不了「失败时目标是否仍是旧版本」这条规矩。 +//! +//! # 跨卷为什么不能退化成「直接覆盖」 +//! +//! Windows 上正在运行的 exe 不能覆盖、只能改名;`rename` **不能跨卷**(`std` 不带 +//! `MOVEFILE_COPY_ALLOWED`,也不会偷偷降级成复制)。所以唯一既原子又能跨卷的姿势是: +//! 先把字节搬到**目标同目录**的临时名(同卷内改名 = 原子),再由一次同卷改名把临时名换成目标名。 +//! 目标路径上的新字节永远来自一次原子命名,任何时刻都不存在「拷了一半的目标文件」。 + +use std::fs; +use std::io; +use std::path::Path; +use std::time::Duration; + +/// 替换策略:改名失败时重试几次、每次间隔多久。 +/// +/// 默认 5 次 × 80ms:足够扛过杀软扫描/索引器短暂占用,又不至于让用户在「更新」按钮上 +/// 干等半分钟。真正长期的占用(另一个启动器实例在跑)重试多少次都没用,直接报错更诚实。 +/// +/// 跨卷(`CrossesDevices`)**不重试**:换多少次都不会变成同卷,那是复制降级该接的信号 +/// (见 `install::materialize`)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReplacePolicy { + /// 总尝试次数(含首次),`0` 按 `1` 处理。 + pub attempts: u32, + /// 两次尝试之间的等待。 + pub delay: Duration, +} + +impl Default for ReplacePolicy { + fn default() -> Self { + Self { + attempts: 5, + delay: Duration::from_millis(80), + } + } +} + +impl ReplacePolicy { + /// 构造。 + #[must_use] + pub const fn new(attempts: u32, delay: Duration) -> Self { + Self { attempts, delay } + } +} + +/// 替换用到的三个文件动作(真实现 = `std::fs`)。 +pub(crate) trait ReplaceFs { + /// 同卷改名。 + fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; + /// 复制(跨卷降级用);返回实际写出的字节数。 + fn copy(&self, from: &Path, to: &Path) -> io::Result; + /// 删文件(清理中转名)。 + fn remove(&self, path: &Path) -> io::Result<()>; +} + +/// 真实现。 +pub(crate) struct RealFs; + +impl ReplaceFs for RealFs { + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + fs::rename(from, to) + } + + fn copy(&self, from: &Path, to: &Path) -> io::Result { + fs::copy(from, to) + } + + fn remove(&self, path: &Path) -> io::Result<()> { + fs::remove_file(path) + } +} + +/// 改名,失败时按策略重试(**只有占用类错误**值得重试)。 +pub(crate) fn rename_with_retry( + fs: &dyn ReplaceFs, + from: &Path, + to: &Path, + policy: ReplacePolicy, +) -> io::Result<()> { + let attempts = policy.attempts.max(1); + let mut last = None; + for attempt in 0..attempts { + match fs.rename(from, to) { + Ok(()) => return Ok(()), + Err(error) => { + let retryable = is_transient(&error) && attempt + 1 < attempts; + if !retryable { + return Err(error); + } + std::thread::sleep(policy.delay); + last = Some(error); + } + } + } + Err(last.unwrap_or_else(|| io::Error::other("rename 未执行"))) +} + +/// 是否属于「等一会儿可能就好了」的失败。 +/// +/// Windows 的共享冲突(`os error 32`)落在 `ErrorKind::Other` 里(`std` 没有专门的 kind), +/// 所以这里必须把 `Other` 也算上;`PermissionDenied` 覆盖 `ACCESS_DENIED`(杀软持有的句柄)。 +/// `CrossesDevices` **不在**这里:它不是占用,是「这块地搬不过去」,重试零收益。 +pub(crate) fn is_transient(error: &io::Error) -> bool { + matches!( + error.kind(), + io::ErrorKind::PermissionDenied + | io::ErrorKind::Other + | io::ErrorKind::Interrupted + | io::ErrorKind::WouldBlock + ) +} + +/// 是否「源与目标不同卷」:Unix `EXDEV` / Windows `ERROR_NOT_SAME_DEVICE`,`std` 都映射到 +/// [`io::ErrorKind::CrossesDevices`]。 +pub(crate) fn is_cross_device(error: &io::Error) -> bool { + error.kind() == io::ErrorKind::CrossesDevices +} diff --git a/crates/core/src/update/tests/install_cases.rs b/crates/core/src/update/tests/install_cases.rs index 64a2365..6fa153e 100644 --- a/crates/core/src/update/tests/install_cases.rs +++ b/crates/core/src/update/tests/install_cases.rs @@ -5,14 +5,14 @@ //! 撞共享冲突。它不是「运行中的 exe」(那需要真跑一个子进程,属真机验收),这一点在报告里写明。 use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use crate::update::manifest::ReleaseManifest; use crate::update::{ install_staged, stage, DirSource, InstallFailure, ReplacePolicy, StagedPackage, }; -use super::support::{artifact, manifest, release, TempDir}; +use super::support::{artifact, manifest, names_in, release, TempDir}; const UPDATE: &[u8] = b"new build bytes"; const OLD: &[u8] = b"old build bytes"; @@ -41,20 +41,6 @@ fn staged(tmp: &TempDir) -> StagedPackage { .expect("取包与校验应通过") } -fn names_in(dir: &Path) -> Vec { - match fs::read_dir(dir) { - Ok(entries) => { - let mut names = entries - .filter_map(|entry| entry.ok()) - .map(|entry| entry.file_name().to_string_lossy().into_owned()) - .collect::>(); - names.sort(); - names - } - Err(_) => Vec::new(), - } -} - #[test] fn installs_over_an_existing_file_and_keeps_the_backup() { let tmp = TempDir::new("install-ok"); @@ -190,3 +176,44 @@ fn backup_path_stays_in_the_same_directory() { "同一进程两次调用不能撞名" ); } + +/// 备份只留最近一份:连装两次不会在安装目录里堆两个几十 MB 的旧 exe。 +/// +/// 旧行为是「每次 apply 留一个、永不删」(全仓没有任何调用方调 `remove_backup`),这条用例把 +/// 上界钉在「一次安装一份」上。它抓得住「prune 被删掉/被改坏」;抓不住**并发**:两个进程同时 +/// 替换同一路径本来就不成立(后者的回滚会把前者的新版本换回旧字节),这一点写在 `install` 的 +/// 文档里,不是这里能验的。 +#[test] +fn only_the_latest_backup_is_kept() { + let tmp = TempDir::new("install-retention"); + let dest = tmp.write("app/ull.exe", OLD); + // 上一次也没人删的备份(名字形态与 `backup_path` 一致,只是 pid/seq 不同)。 + tmp.write("app/.ull.exe.ullold-1-1", b"older old bytes"); + tmp.write("app/.ull.exe.ullold-1-2", b"even older bytes"); + // 别的程序的备份:不是我们的名字,一个都不许动。 + tmp.write("app/.other.exe.ullold-1-1", b"someone else's backup"); + // 上一次失败遗留的中转文件:垃圾,要在这次成功之后清掉。 + tmp.write( + "app/.ull.exe.ullnew-1-1", + b"half a build from a dead attempt", + ); + + let staged = staged(&tmp); + let receipt = install_staged(&staged, &dest, policy()).expect("替换应成功"); + let backup = receipt.backup.clone().expect("本次备份"); + + assert_eq!(fs::read(&backup).expect("本次备份"), OLD); + assert_eq!( + names_in(dest.parent().expect("父目录")), + [ + ".other.exe.ullold-1-1".to_owned(), + backup + .file_name() + .expect("备份名") + .to_string_lossy() + .into_owned(), + "ull.exe".to_owned(), + ], + "只该留本次备份,且不许碰别的程序的备份" + ); +} diff --git a/crates/core/src/update/tests/install_io_cases.rs b/crates/core/src/update/tests/install_io_cases.rs new file mode 100644 index 0000000..3f19524 --- /dev/null +++ b/crates/core/src/update/tests/install_io_cases.rs @@ -0,0 +1,271 @@ +//! 替换的坏路径:跨卷降级、复制到一半失败、截断复制、最后一步改名失败、同卷不多搬一个字节。 +//! +//! # 为什么要注入而不是造真盘 +//! +//! 「跨卷」在 `install_xdev_cases` 里是**真跨卷**(真盘、真 `EXDEV`),但那需要机器上有第二块卷; +//! 「复制写到一半失败」在真盘上根本没法确定性复现(要么让盘满,要么让杀软恰好在那一刻咬住)。 +//! 这两条恰好是本次修复最关键的路径,所以这里用 [`ReplaceFs`] 的注入器把系统调用变成输入: +//! +//! * 注入器能改的只有「rename/copy 这一次成不成功」; +//! * 注入器**改不了**判据与顺序——`install_with` 与生产路径是同一份代码,只有 `&dyn` 后面那个 +//! 实现不同。 +//! +//! 抓得住:跨卷时是否真的降级到「复制到目标同目录的中转名」;复制半途失败后目标是否仍是旧字节、 +//! 中转名是否被清掉;截断的复制是否被字节数判据拒掉;同卷是否一个字节都没多搬。 +//! 抓不住:真实文件系统的掉电/写缓存语义(`fsync` 从未被调用过),以及「复制期间目标盘被拔掉」 +//! 这类整卷消失的场景。 + +use std::cell::Cell; +use std::fs; +use std::io; +use std::path::Path; + +use crate::update::manifest::ReleaseManifest; +use crate::update::replace_io::ReplaceFs; +use crate::update::{install::install_with, stage, DirSource, ReplacePolicy, StagedPackage}; + +use super::support::{artifact, manifest, names_in, release, TempDir}; + +const UPDATE: &[u8] = b"new build bytes that must land whole"; +const OLD: &[u8] = b"old build bytes"; + +fn policy() -> ReplacePolicy { + ReplacePolicy::new(1, std::time::Duration::from_millis(1)) +} + +fn staged(tmp: &TempDir) -> StagedPackage { + tmp.write("ull-test.zip", UPDATE); + let json = manifest( + "stable", + &[release( + "0.3.0", + "", + &[artifact("win-x64", "portable-exe", "ull-test.zip", UPDATE)], + )], + ); + let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析"); + stage( + &DirSource::new(tmp.path()), + &parsed.releases[0].artifacts[0], + &tmp.join("staging"), + ) + .expect("取包与校验应通过") +} + +/// 复制这一步的表现。 +#[derive(Clone, Copy)] +enum CopyMode { + /// 老实复制。 + Normal, + /// 只写 `n` 字节就报错(真盘上「拷到一半失败」留下的半截文件)。 + PartialThenError(usize), + /// 只写 `n` 字节但**声称**成功(模拟「长度判据必须抓住」的那一类坏复制)。 + TruncateThenOk(usize), + /// 一律拒绝(用来证明同卷路径上没有复制)。 + Forbidden, +} + +/// 注入器:默认全权交给 `std::fs`,按开关制造跨卷与失败。 +struct FakeFs { + /// 「暂存包 → 中转名」这一步假装跨卷。 + staged_rename_crosses: bool, + /// 中转名 → dest 这一步失败。 + fail_final_rename: bool, + copy_mode: CopyMode, + copies: Cell, + renames: Cell, +} + +impl FakeFs { + fn new() -> Self { + Self { + staged_rename_crosses: false, + fail_final_rename: false, + copy_mode: CopyMode::Normal, + copies: Cell::new(0), + renames: Cell::new(0), + } + } + + fn across_volumes() -> Self { + Self { + staged_rename_crosses: true, + ..Self::new() + } + } + + fn copies(&self) -> usize { + self.copies.get() + } + + fn renames(&self) -> usize { + self.renames.get() + } +} + +fn is_transit(path: &Path) -> bool { + path.file_name() + .is_some_and(|name| name.to_string_lossy().contains(".ullnew-")) +} + +impl ReplaceFs for FakeFs { + fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { + self.renames.set(self.renames.get() + 1); + if self.staged_rename_crosses && is_transit(to) { + return Err(io::Error::from(io::ErrorKind::CrossesDevices)); + } + if self.fail_final_rename && is_transit(from) { + return Err(io::Error::other("模拟:中转名改名到目标失败")); + } + fs::rename(from, to) + } + + fn copy(&self, from: &Path, to: &Path) -> io::Result { + self.copies.set(self.copies.get() + 1); + let bytes = fs::read(from)?; + match self.copy_mode { + CopyMode::Normal => fs::copy(from, to), + CopyMode::PartialThenError(wrote) => { + fs::write(to, &bytes[..wrote.min(bytes.len())])?; + Err(io::Error::other("模拟:复制到一半失败")) + } + CopyMode::TruncateThenOk(wrote) => { + let wrote = wrote.min(bytes.len()); + fs::write(to, &bytes[..wrote])?; + Ok(wrote as u64) + } + CopyMode::Forbidden => Err(io::Error::other("这里不许复制")), + } + } + + fn remove(&self, path: &Path) -> io::Result<()> { + fs::remove_file(path) + } +} + +/// 目标目录里只该有 `ull.exe`(+ 可能的一份备份),中转名一律不许留。 +fn assert_no_transit(dir: &Path) { + let names = names_in(dir); + assert!( + names.iter().all(|name| !name.contains(".ullnew-")), + "中转名必须被清理:{names:?}" + ); +} + +/// ① 跨卷:降级为「复制到目标同目录的中转名」,装上去的必须是完整字节。 +#[test] +fn cross_volume_falls_back_to_a_copy_beside_the_target() { + let fake = FakeFs::across_volumes(); + let tmp = TempDir::new("io-xdev-ok"); + let dest = tmp.write("app/ull.exe", OLD); + let staged = staged(&tmp); + let receipt = install_with(&fake, &staged, &dest, policy()).expect("跨卷必须成功"); + + assert_eq!(fs::read(&dest).expect("目标应存在"), UPDATE); + assert_eq!(receipt.bytes, UPDATE.len() as u64); + assert_eq!( + fake.copies(), + 1, + "跨卷只该复制一次(降级路径唯一允许的复制)" + ); + let backup = receipt.backup.clone().expect("旧版本应有备份"); + assert_eq!(fs::read(&backup).expect("备份"), OLD); + assert_no_transit(dest.parent().expect("父目录")); +} + +/// ② 复制写到一半失败:目标仍是旧字节、不留中转名、也不该动过备份。 +#[test] +fn a_copy_that_dies_halfway_leaves_the_old_bytes_and_no_garbage() { + let fake = FakeFs { + copy_mode: CopyMode::PartialThenError(UPDATE.len() / 2), + ..FakeFs::across_volumes() + }; + let tmp = TempDir::new("io-xdev-partial"); + let dest = tmp.write("app/ull.exe", OLD); + let staged = staged(&tmp); + let failure = install_with(&fake, &staged, &dest, policy()).expect_err("半截复制必须失败"); + + assert!( + failure.rolled_back, + "目标没被碰过,旧版本仍在原位:{failure}" + ); + assert_eq!( + fs::read(&dest).expect("旧版本"), + OLD, + "目标必须是完整的旧字节" + ); + assert_no_transit(dest.parent().expect("父目录")); + assert!( + names_in(dest.parent().expect("父目录")) == ["ull.exe"], + "连备份都不该建(失败发生在动目标之前):{:?}", + names_in(dest.parent().expect("父目录")) + ); +} + +/// ②′ 复制「声称成功」但字节数不符:长度判据必须拒掉,目标不动。 +#[test] +fn a_short_copy_is_rejected_by_the_byte_count() { + let fake = FakeFs { + copy_mode: CopyMode::TruncateThenOk(UPDATE.len() - 1), + ..FakeFs::across_volumes() + }; + let tmp = TempDir::new("io-xdev-short"); + let dest = tmp.write("app/ull.exe", OLD); + let staged = staged(&tmp); + let failure = install_with(&fake, &staged, &dest, policy()).expect_err("短了必须拒"); + + assert!(failure.rolled_back, "{failure}"); + assert_eq!(fs::read(&dest).expect("旧版本"), OLD); + assert!( + failure.error.message.contains("跨卷复制只写出"), + "失败原因要说清是字节数不符:{failure}" + ); + assert_no_transit(dest.parent().expect("父目录")); +} + +/// ②″ 最后一步改名失败:回滚到旧字节,中转名与备份都不留。 +#[test] +fn a_failed_final_rename_rolls_back_and_cleans_up() { + let fake = FakeFs { + fail_final_rename: true, + ..FakeFs::across_volumes() + }; + let tmp = TempDir::new("io-final-rename"); + let dest = tmp.write("app/ull.exe", OLD); + let staged = staged(&tmp); + let failure = install_with(&fake, &staged, &dest, policy()).expect_err("改名失败必须报错"); + + assert!(failure.rolled_back, "旧版本应回到原位:{failure}"); + assert_eq!(failure.backup_left, None); + assert_eq!(fs::read(&dest).expect("旧版本"), OLD); + assert_eq!( + names_in(dest.parent().expect("父目录")), + ["ull.exe"], + "中转名与备份都必须被清掉" + ); +} + +/// ③ 同卷:一个字节都不许复制(行为与修前一致)。 +/// +/// 注入器的 `copy` 一律报错——只要实现走了复制,这条就会红。 +#[test] +fn the_same_volume_path_never_copies() { + let fake = FakeFs { + copy_mode: CopyMode::Forbidden, + ..FakeFs::new() + }; + let tmp = TempDir::new("io-same-volume"); + let dest = tmp.write("app/ull.exe", OLD); + let staged = staged(&tmp); + let receipt = install_with(&fake, &staged, &dest, policy()).expect("同卷替换必须成功"); + + assert_eq!(fs::read(&dest).expect("目标"), UPDATE); + assert_eq!(fake.copies(), 0, "同卷走改名,不许有复制"); + assert_eq!( + fake.renames(), + 3, + "同卷三步:暂存→中转名、目标→备份、中转名→目标(比修前多一次同卷改名、不多一个字节)" + ); + assert_eq!(receipt.bytes, UPDATE.len() as u64); + assert_no_transit(dest.parent().expect("父目录")); +} diff --git a/crates/core/src/update/tests/install_xdev_cases.rs b/crates/core/src/update/tests/install_xdev_cases.rs new file mode 100644 index 0000000..4c2b6b3 --- /dev/null +++ b/crates/core/src/update/tests/install_xdev_cases.rs @@ -0,0 +1,105 @@ +//! 跨卷替换:暂存包在 A 卷、目标 exe 在 B 卷时,替换必须成功且**不留半截文件**。 +//! +//! # 这条用例为什么必须存在 +//! +//! `stage()` 把包落在 `<数据根>/cache/update/staging`,`install_staged()` 把包搬进 exe 所在目录。 +//! 两者不在同一卷时(便携版放 U 盘、数据根设到 D 盘而程序在 C 盘、部分 macOS 配置), +//! `fs::rename` 直接返回 `EXDEV`/`ERROR_NOT_SAME_DEVICE`(`CrossesDevices`)——M169 复审 +//! 在已发布版本上判定「更新必然失败」。修法是**先把包落到目标同目录的临时名,再同卷改名**: +//! 目标路径上的新字节永远来自一次原子命名,不存在「拷到一半」的中间态。 +//! +//! # 跨卷是探出来的,不是假设出来的 +//! +//! [`XdevRoot::probe`] 用一次真实 `rename` 验证候选目录**真的**在另一块卷上;找不到就跳过并 +//! 打一行说明(`cargo test -- --nocapture` 可见)。所以: +//! +//! * 有第二块卷的机器(本仓开发机:`C:` NVMe + `D:` 机械盘)→ 真跨卷; +//! * 只有一块卷的机器 / CI → 跳过,这条守卫**不生效**(同卷覆盖靠 `install_cases` 与注入用例)。 + +use std::fs; + +use crate::update::manifest::ReleaseManifest; +use crate::update::{install_staged, stage, DirSource, ReplacePolicy, StagedPackage}; + +use super::support::{artifact, manifest, names_in, release, TempDir, XdevRoot}; + +const UPDATE: &[u8] = b"new build bytes, cross volume"; +const OLD: &[u8] = b"old build bytes"; + +fn policy() -> ReplacePolicy { + ReplacePolicy::new(2, std::time::Duration::from_millis(1)) +} + +/// 在 `tmp`(会话临时目录 = A 卷)里造一份已校验的暂存包。 +fn staged_on_a_volume(tmp: &TempDir) -> StagedPackage { + tmp.write("ull-test.zip", UPDATE); + let json = manifest( + "stable", + &[release( + "0.3.0", + "", + &[artifact("win-x64", "portable-exe", "ull-test.zip", UPDATE)], + )], + ); + let parsed = ReleaseManifest::from_json(&json).expect("清单应可解析"); + stage( + &DirSource::new(tmp.path()), + &parsed.releases[0].artifacts[0], + &tmp.join("staging"), + ) + .expect("取包与校验应通过") +} + +/// ① 跨卷安装必须成功,且目标字节 = 包体字节(不是半截、不是旧字节)。 +#[test] +fn installs_across_volumes_and_lands_the_exact_bytes() { + let Some(xdev) = XdevRoot::probe("install") else { + eprintln!( + "跳过跨卷用例:本机找不到第二块可写卷(可用 ULL_XDEV_TEST_ROOT 指一块,\ + 例如 C:/Temp + D:/Temp)" + ); + return; + }; + let tmp = TempDir::new("xdev-install"); + let staged = staged_on_a_volume(&tmp); + let app = xdev.dir("app"); + let dest = app.join("ull.exe"); + fs::write(&dest, OLD).expect("写旧版本"); + + // 前提断言:暂存与目标**真的**不在同一卷(否则这条用例只是在同卷上重跑一遍)。 + let cross = + fs::rename(staged.path(), app.join("probe-move")).expect_err("跨卷 rename 必须失败"); + assert_eq!( + cross.kind(), + std::io::ErrorKind::CrossesDevices, + "前提不成立:暂存与目标同卷({cross})" + ); + + let receipt = install_staged(&staged, &dest, policy()).expect("跨卷替换必须成功"); + + assert_eq!( + fs::read(&dest).expect("目标应存在"), + UPDATE, + "目标必须就是包体字节" + ); + assert_eq!(receipt.bytes, UPDATE.len() as u64); + assert_eq!(receipt.dest, dest); + let backup = receipt.backup.clone().expect("旧版本应留备份"); + assert_eq!(backup.parent(), dest.parent(), "备份必须与目标同目录"); + assert_eq!( + fs::read(&backup).expect("备份应存在"), + OLD, + "旧字节要完整留在备份里" + ); + let names = names_in(&app); + assert_eq!( + names.len(), + 2, + "目标目录里只该有目标与一份备份(跨卷中转的临时名必须被改掉、不许残留):{names:?}" + ); + assert_eq!(names[1], "ull.exe", "排序后目标在后:{names:?}"); + assert!( + names[0].starts_with(".ull.exe.ullold-"), + "另一个必须是备份:{names:?}" + ); +} diff --git a/crates/core/src/update/tests/mod.rs b/crates/core/src/update/tests/mod.rs index ac0116e..a2a5756 100644 --- a/crates/core/src/update/tests/mod.rs +++ b/crates/core/src/update/tests/mod.rs @@ -6,7 +6,9 @@ //! * 清单校验:重复版本、零长度、缺摘要/摘要格式错、未知平台与格式; //! * 渠道准入:阶梯比较 + 预发布阶段表; //! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留; -//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用; +//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用、 +//! 跨卷(暂存在 A 卷、目标在 B 卷)安装成功且字节完整、中途失败不留半截文件、 +//! 同卷不多搬一个字节、旧备份只留最近一份; //! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、 //! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来, //! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给); @@ -19,6 +21,8 @@ mod channel_cases; mod checker_cases; mod github_cases; mod install_cases; +mod install_io_cases; +mod install_xdev_cases; mod job_cases; mod manifest_cases; mod source_cases; diff --git a/crates/core/src/update/tests/support.rs b/crates/core/src/update/tests/support.rs index 7847076..746bbd7 100644 --- a/crates/core/src/update/tests/support.rs +++ b/crates/core/src/update/tests/support.rs @@ -54,6 +54,118 @@ impl Drop for TempDir { } } +/// 目录里的文件名(读不到就当空;排序过,断言可写死)。 +pub(crate) fn names_in(dir: &Path) -> Vec { + match fs::read_dir(dir) { + Ok(entries) => { + let mut names = entries + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>(); + names.sort(); + names + } + Err(_) => Vec::new(), + } +} + +/// 指定异卷根目录的环境变量(跨卷夹具找不到别的卷时,用它显式指一块)。 +pub(crate) const XDEV_ROOT_ENV: &str = "ULL_XDEV_TEST_ROOT"; + +/// 一块**与会话临时目录不同卷**的可写目录:跨卷替换用例的现场。 +/// +/// 「不同卷」不是按平台猜的,是**探测**出来的:往候选目录 rename 一个探针文件,只有真的报 +/// `CrossesDevices`(Unix `EXDEV` / Windows `ERROR_NOT_SAME_DEVICE`)才认这块地——否则 +/// 用例会在同一卷上跑,等于什么都没验。 +/// +/// 候选顺序:`ULL_XDEV_TEST_ROOT` → 各盘符(Windows:`<盘>:\Temp`,没有就盘根)/ +/// `TMPDIR`、`/dev/shm`、`/run/user/`、`/tmp`(Unix)。 +pub(crate) struct XdevRoot { + path: PathBuf, +} + +impl XdevRoot { + /// 探一块异卷目录;找不到返回 `None`(调用方**跳过**并在输出里说明原因)。 + pub(crate) fn probe(tag: &str) -> Option { + let mut candidates = Vec::new(); + if let Some(root) = std::env::var_os(XDEV_ROOT_ENV) { + candidates.push(PathBuf::from(root)); + } + candidates.extend(volume_candidates()); + candidates.into_iter().find_map(|candidate| { + let path = candidate.join(format!("ull-xdev-{}-{tag}", std::process::id())); + if !is_other_volume(&candidate) || fs::create_dir_all(&path).is_err() { + return None; + } + Some(Self { path }) + }) + } + + /// 现场根目录下的子目录(自动建)。 + pub(crate) fn dir(&self, name: &str) -> PathBuf { + let path = self.path.join(name); + fs::create_dir_all(&path).expect("建异卷子目录"); + path + } +} + +impl Drop for XdevRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +/// 可能的异卷候选根(只是候选,能不能用由 [`is_other_volume`] 定)。 +fn volume_candidates() -> Vec { + #[cfg(windows)] + { + ('A'..='Z') + .map(|letter| format!("{letter}:/")) + .map(PathBuf::from) + .filter(|root| root.join("Temp").is_dir() || root.is_dir()) + .map(|root| { + if root.join("Temp").is_dir() { + root.join("Temp") + } else { + root + } + }) + .collect() + } + #[cfg(not(windows))] + { + let mut candidates = Vec::new(); + for key in ["TMPDIR", "TMP", "TEMP"] { + if let Some(value) = std::env::var_os(key) { + candidates.push(PathBuf::from(value)); + } + } + for root in ["/dev/shm", "/tmp", "/var/tmp"] { + candidates.push(PathBuf::from(root)); + } + if let Some(uid) = std::env::var_os("UID") { + candidates.push(PathBuf::from("/run/user").join(uid)); + } + candidates + } +} + +/// 这块目录与 [`std::env::temp_dir`] 是否**真的**不同卷(拿一次真实的 rename 试出来)。 +fn is_other_volume(dir: &Path) -> bool { + if dir.as_os_str().is_empty() || fs::create_dir_all(dir).is_err() { + return false; + } + let probe = std::env::temp_dir().join(format!("ull-xdev-probe-{}", std::process::id())); + if fs::write(&probe, b"probe").is_err() { + return false; + } + let landed = dir.join(format!("ull-xdev-probe-{}", std::process::id())); + let cross = matches!(fs::rename(&probe, &landed), Err(ref error) if error.kind() == std::io::ErrorKind::CrossesDevices); + let _ = fs::remove_file(&probe); + let _ = fs::remove_file(&landed); + cross +} + /// 小写十六进制 SHA-256。 pub(crate) fn sha256(bytes: &[u8]) -> String { digest_bytes(HashAlgo::Sha256, bytes)