diff --git a/crates/cli/tests/support/mod.rs b/crates/cli/tests/support/mod.rs index bee4760..f2596da 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,107 @@ impl Drop for TempDir { } } +/// 指定异卷根目录的环境变量(本机找不到第二块卷时,用它显式指一块)。 +pub const XDEV_ROOT_ENV: &str = "ULL_XDEV_TEST_ROOT"; + +/// 「我知道本机没有第二块卷」的显式豁免(值必须是 `1`)。 +/// +/// 与 `crates/core/src/update/tests/support.rs` 的同名常量是**同一条政策**(源头在 +/// `crates/core/src/update/tests/install_xdev_cases.rs::xdev_guard`,改一处要改两处): +/// 探不到第二块卷又不豁免 ⇒ 这条 e2e 必须红,不许静静 `return`(M171 r2 P2-1)。 +pub const XDEV_WAIVE_ENV: &str = "ULL_XDEV_TEST_WAIVE"; + +/// 「本机/本作业必须有第二块卷」的显式声明(值必须是 `1`),优先于豁免。 +pub const XDEV_REQUIRE_ENV: &str = "ULL_XDEV_TEST_REQUIRE"; + +/// 跨卷 e2e 探不到第二块卷时的裁决:**显式豁免**才允许跳过(打印一行记录),否则 panic。 +/// +/// `what` 只用来在输出里点名是哪条用例,裁决规则与 core 侧完全一致(见 [`XDEV_WAIVE_ENV`])。 +pub fn cross_volume_waived(what: &str) { + let required = std::env::var(XDEV_REQUIRE_ENV).ok(); + let waived = std::env::var(XDEV_WAIVE_ENV).ok(); + let required = required.as_deref().map(str::trim) == Some("1"); + let waived = waived.as_deref().map(str::trim) == Some("1"); + assert!( + waived && !required, + "[xdev-guard] {what}:本机没有第二块可写卷,也没有显式豁免——跨卷 e2e 空跑了,\ + 所以判红。三种合规做法任选其一:\ + ① ULL_XDEV_TEST_ROOT=<另一块卷上的可写目录>;② ULL_XDEV_TEST_WAIVE=1(值必须是 1);\ + ③ 认为本机本该有第二块卷就用 ULL_XDEV_TEST_REQUIRE=1 声明。\ + 政策与理由见 crates/core/src/update/tests/install_xdev_cases.rs 的模块文档。" + ); + eprintln!( + "[xdev-guard] {what}:本机没有第二块可写卷,已显式豁免({XDEV_WAIVE_ENV}=1),\ + 这条 e2e 跳过。" + ); +} + +/// 一块与会话临时目录**真的**不同卷的可写根目录(探出来的,不是按平台猜的)。 +/// +/// 候选:`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..ce342e2 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`) | 只有一块可写卷的机器上**只有显式豁免才允许跳过**(`ULL_XDEV_TEST_WAIVE=1`,探不到又没豁免 ⇒ 红);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_artifacts`,M171);第二次之所以还会「装上」,是因为 `current` 是编译期 +/// 版本——重复执行不是空操作,只是结果幂等。 #[test] fn applying_twice_leaves_the_same_target_bytes() { let _serial = channel_lock(); @@ -189,8 +194,66 @@ 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`),否则 +/// 「跨卷成功」这句话就是假的。本机只有一块可写卷时**只有显式豁免才允许跳过** +/// (`ULL_XDEV_TEST_WAIVE=1`,见 `support::cross_volume_waived`):探不到又没豁免 ⇒ 红。 +/// 跨卷逻辑本身另由 `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 { + support::cross_volume_waived("cli-e2e"); + 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..f8c492e 100644 --- a/crates/core/src/update/install.rs +++ b/crates/core/src/update/install.rs @@ -1,4 +1,5 @@ -//! 原子替换:先把旧文件改名挪开,再把新文件搬进来;任一步失败都能回滚。 +//! 原子替换:先把新包落到**目标同目录**的中转名,再把旧文件改名挪开、中转名顶上; +//! 任一步失败都能回滚,**目标路径上永远不会出现半截文件**。 //! //! # 为什么不用「直接覆盖」 //! @@ -8,16 +9,43 @@ //! 把旧文件挪到同目录的备份名,让新文件顶替原来的路径。而改名本身也可能被拒绝(杀软扫描 //! 句柄、编辑器打开、另一个进程正在写),所以失败必须能回到旧版本——这正是本模块存在的理由。 //! -//! 三步写法,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。 +//! +//! **一条刻意的例外**:本次安装前 `dest` 就不存在时(`keep = None`)一个 `.ullold-*` 都不删, +//! 只清残留的 `.ullnew-*`。理由见 `prune_stale_artifacts`:那个时刻的旧备份可能是崩溃现场里 +//! **唯一幸存的旧 exe**(上一次装在「dest → 备份」之后、「中转名 → dest」之前被杀), +//! 删掉它等于抹掉用户手工恢复的最后一份材料。 +//! //! # 本模块**不**负责的事 //! //! * **不判断现在能不能替换**:占用/杀软/权限都在 [`ReplacePolicy`] 的重试里撞,撞不动就回滚。 @@ -27,10 +55,10 @@ //! (改字节这条路根本不存在)。这也是本模块「先改名、再搬入」而不是「直接覆盖」的原因。 //! 别照早先那版文档去设计「退出自身 → 由另一个进程搬运」的双进程方案(HMCL 的 `--apply-to`): //! 那条路在 Windows 上不是必需的。(Unix 上 `rename` 本就可以覆盖,两步写法是为了两边同一条 -//! 回滚路径,见下。) +//! 回滚路径,见上。) //! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器, //! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。 -//! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再 +//! * **不重启、不删备份**(最新一份何时删由上层决定:新版启动成功后 //! [`InstallReceipt::remove_backup`])。 //! //! # 单测覆盖到哪 @@ -40,51 +68,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 +129,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 +175,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 +202,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 +247,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 +290,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 +311,57 @@ 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) +} + +/// 清同目录里的替换残留:只留最近一份旧版本备份,顺带扫掉更早失败遗留的中转名。 +/// +/// * `keep = Some(本次新建的备份)`:删掉同目录里**同名目标**更早的 `.<名>.ullold-*`,只留这一份; +/// * `keep = None`(本次安装前 `dest` 本来不存在):**一个 `.ullold-*` 都不动**,只清 `.ullnew-*`。 +/// +/// `keep = None` 这条例外是 M171 r2 P3-2 的结论:`.<名>.ullold-*` 在那个时刻极可能是**用户旧版本 +/// 唯一幸存的副本**——上一次安装在「dest → 备份」之后、「中转名 → dest」之前被杀(掉电、被结束 +/// 进程),于是 dest 空着、旧字节全在备份里。把它当垃圾删掉,用户就彻底失去手工恢复的材料; +/// 而修前(没有清理)根本不会删。宁可留一份几十 MB 的垃圾:等 dest 重新存在(下一次成功替换 +/// ⇒ `keep = Some`),这些旧备份会自然收敛成一份。 +/// +/// `.ullnew-*` 则任何时候都是垃圾:本次那份在被调用前已经改名成 dest 了,还留着的只可能是 +/// 更早某次失败遗留的中转文件(本次失败路径自己会删,删不掉才会留到这里)。 +/// +/// 删失败不当错误(只是垃圾文件,下次 apply 再清);只在替换成功之后调用,所以正在用的 +/// 那一份(`keep`)不会被动。 +/// +/// 前提是「同一个 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(&new_prefix) || (keep.is_some() && other.starts_with(&old_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..7094028 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,84 @@ 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(), + ], + "只该留本次备份,且不许碰别的程序的备份" + ); +} + +/// 崩溃现场:上一次装到「dest → 备份」之后被杀,`dest` 空着、旧字节只剩备份那一份。 +/// +/// 这种情况下本次安装的 `keep = None`(dest 原本不存在 ⇒ 不建新备份),清理**必须放过** +/// `.ullold-*`——它是用户旧版本唯一幸存的副本,删了就没法手工恢复了。`.ullnew-*` 仍是垃圾, +/// 该清。变异实测:把 `prune_stale_artifacts` 里 `keep.is_some() &&` 这一半去掉 ⇒ 本条红。 +#[test] +fn a_crashed_install_keeps_the_only_surviving_old_copy() { + let tmp = TempDir::new("install-crash-scene"); + let app = tmp.join("app"); + fs::create_dir_all(&app).expect("建安装目录"); + // 崩现场:没有 ulls.exe,旧字节只在备份里。 + let survivor = tmp.write("app/.ull.exe.ullold-1-1", OLD); + let transit = tmp.write( + "app/.ull.exe.ullnew-1-1", + b"half a build from a dead attempt", + ); + + let staged = staged(&tmp); + let dest = app.join("ull.exe"); + let receipt = install_staged(&staged, &dest, policy()).expect("新装应成功"); + + assert_eq!(receipt.backup, None, "dest 原本不存在 ⇒ 本次不建备份"); + assert_eq!(fs::read(&dest).expect("目标"), UPDATE); + assert!( + survivor.exists(), + "崩溃现场唯一幸存的旧 exe 不许被删(用户靠它手工恢复):{:?}", + names_in(&app) + ); + assert_eq!( + fs::read(&survivor).expect("幸存副本"), + OLD, + "旧字节要原样留着" + ); + assert!( + !transit.exists(), + "残留的中转名仍是垃圾,该清:{:?}", + names_in(&app) + ); +} 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..dcb0391 --- /dev/null +++ b/crates/core/src/update/tests/install_xdev_cases.rs @@ -0,0 +1,274 @@ +//! 跨卷替换:暂存包在 A 卷、目标 exe 在 B 卷时,替换必须成功且**不留半截文件**, +//! 并且「凑不出跨卷现场」这件事**必须是响的**。 +//! +//! # 这条用例为什么必须存在 +//! +//! `stage()` 把包落在 `<数据根>/cache/update/staging`,`install_staged()` 把包搬进 exe 所在目录。 +//! 两者不在同一卷时(便携版放 U 盘、数据根设到 D 盘而程序在 C 盘、部分 macOS 配置), +//! `fs::rename` 直接返回 `EXDEV`/`ERROR_NOT_SAME_DEVICE`(`CrossesDevices`)——M169 复审 +//! 在已发布版本上判定「更新必然失败」。修法是**先把包落到目标同目录的临时名,再同卷改名**: +//! 目标路径上的新字节永远来自一次原子命名,不存在「拷到一半」的中间态。 +//! +//! # 跨卷是探出来的,探不到**不许静默变绿**(M171 r2 P2-1) +//! +//! [`XdevRoot::probe`] 用一次真实 `rename` 验证候选目录**真的**在另一块卷上。问题是「探不到」 +//! 以前写成 `eprintln! + return`:libtest 默认捕获 stdout/stderr,于是这台机器只有一块卷时 +//! 用例显示 `... ok`,跨卷守卫**空跑了却看不出来**(复审 M3 变异实测)。本仓已确立的口径是 +//! 「跳过这件事必须是响的」(同 `crates/app-iced/.../options_shot/feed.rs::not_fed`), +//! 所以裁决被收进 [`xdev_guard`],三档: +//! +//! | 探到第二块卷 | `ULL_XDEV_TEST_WAIVE=1` | 结果 | +//! |---|---|---| +//! | 是 | 无所谓 | **绿**,真跨卷用例真跑(豁免只在探测失败时被咨询) | +//! | 否 | 给了 | **绿**,并打印「已显式豁免」——这是**记录在案的**降级,不是意外 | +//! | 否 | 没给 | **红**:本机/本作业的跨卷守卫没有生效 | +//! +//! 另有 `ULL_XDEV_TEST_REQUIRE=1`(声明「本机必须有第二块卷」):与豁免同时给时以它为准 +//! (声明了必须有、实际没有 ⇒ 红),一句话里不许自相矛盾。`ULL_XDEV_TEST_ROOT` 可以直接 +//! 指定一块异卷目录(Windows 举例:`ULL_XDEV_TEST_ROOT=D:/Temp`)。 +//! +//! # 谁该给哪个 env +//! +//! * **本仓开发机**(`C:` NVMe + `D:` 机械盘,见 [`volume_candidates`]):什么都不用给,探得到。 +//! * **CI `test (ubuntu-latest)`**:`/tmp` 与会话临时目录同卷,但 `/dev/shm` 是另一块 tmpfs ⇒ +//! 通常直接探得到(本机 docker 里实测 `/tmp` 设备号 119、`/dev/shm` 158)。探不到就会红。 +//! * **CI `test (windows-latest)` / `update-e2e (windows-latest)`**:runner 是只有一块系统卷、 +//! 还是额外挂了一块临时盘(`D:`),**本仓没有核实手段**(没有 runner 权限,文档也没写死盘符)。 +//! 所以策略**不赌它**:探到就跑真跨卷;探不到就必须在作业里显式写 +//! `ULL_XDEV_TEST_WAIVE: "1"`——这一行属 `.github/workflows/ci.yml`(不在 M171 的改动范围)。 +//! +//! 只有注入用例(`install_io_cases`)永远在跑:它把「跨卷被拒」「复制到一半失败」直接喂给 +//! `install_with`,不依赖盘的分区布局。 +//! +//! # 探不到时怎么办(三选一) +//! +//! 1. 给一块异卷目录:`ULL_XDEV_TEST_ROOT=<另一块卷上的可写目录>`; +//! 2. 明确豁免:`ULL_XDEV_TEST_WAIVE=1`; +//! 3. 认为自己本该有第二块卷 ⇒ `ULL_XDEV_TEST_REQUIRE=1`,让它在配置坏掉时就地红。 + +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, XDEV_REQUIRE_ENV, XDEV_WAIVE_ENV, +}; + +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)) +} + +/// 跨卷守卫的裁决结果。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum XdevGuard { + /// 探到了第二块卷:真跨卷用例会真跑。 + Live, + /// 没有第二块卷,但调用方**显式**豁免了。 + Waived, +} + +/// 裁决(纯函数:只吃「探到了吗」与两档环境变量,不读进程环境 ⇒ 并行跑不会互相干扰)。 +/// +/// 规则见模块文档的表。`require` 优先于 `waiver`:声明「必须有」又同时说「可以没有」是自相矛盾, +/// 这时按更严的那条办——否则一句手误就把守卫关掉了。 +fn xdev_guard( + found: bool, + waiver: Option<&str>, + require: Option<&str>, +) -> Result { + if found { + return Ok(XdevGuard::Live); + } + // 只认 `1`(`true`/`yes`/空串一律不算:手误不该开闸),两侧空白忽略。 + let required = require.map(str::trim) == Some("1"); + let waived = waiver.map(str::trim) == Some("1"); + if waived && !required { + return Ok(XdevGuard::Waived); + } + Err(xdev_failure(required, waived)) +} + +/// 探不到第二块卷、又不许豁免时的失败正文(把「有哪三条出路」写全,别让人去猜)。 +fn xdev_failure(required: bool, waived: bool) -> String { + let lead = match (required, waived) { + (true, true) => concat!( + "同时给了 ULL_XDEV_TEST_REQUIRE=1 与 ULL_XDEV_TEST_WAIVE=1:自相矛盾。", + "以 REQUIRE 为准(声明了必须有,就没有豁免)。" + ), + (true, _) => { + "给了 ULL_XDEV_TEST_REQUIRE=1(声明本机必须有第二块卷),但这台机器上探不到——那是配置坏了,不是可以跳过。" + } + (false, _) => { + "没有第二块可写卷,也没有显式豁免:跨卷守卫在这台机器上是空跑。" + } + }; + format!( + "[xdev-guard] {lead}\n\ + 这条守卫守的是已发布版本上的真 bug(数据根与程序不同卷 ⇒ 更新必然失败),\ + 空跑的守卫等于没有,所以默认判红。三种合规做法任选其一:\n\ + 1. 给它一块异卷目录:ULL_XDEV_TEST_ROOT=<另一块卷上的可写目录>(本机举例:D:/Temp);\n\ + 2. 明确豁免:ULL_XDEV_TEST_WAIVE=1(值必须是 `1`);\n\ + 3. 认为本机本该有第二块卷 ⇒ 用 ULL_XDEV_TEST_REQUIRE=1 声明,坏掉时就地红。\n\ + CI:ubuntu runner 的 /dev/shm 与 /tmp 是不同设备(通常直接满足第 1 条);\ + windows runner 只有一块卷时,作业里要显式给第 2 条(.github/workflows/ci.yml 的 \ + test 作业与 update-e2e 作业)。" + ) +} + +/// 要一块异卷现场来做真跨卷用例:`Some` = 探到了;`None` = **显式豁免**(已打印记录)。 +/// +/// 探不到又没豁免就 panic——这是给逐条用例用的入口,保证没有哪一条能靠 `return` 静静跳过。 +fn cross_volume_scene(tag: &str) -> Option { + let root = XdevRoot::probe(tag); + let verdict = xdev_guard( + root.is_some(), + std::env::var(XDEV_WAIVE_ENV).ok().as_deref(), + std::env::var(XDEV_REQUIRE_ENV).ok().as_deref(), + ); + match verdict { + Ok(XdevGuard::Live) => root, + Ok(XdevGuard::Waived) => { + eprintln!( + "[xdev-guard] {XDEV_WAIVE_ENV}=1:本机没有第二块可写卷,真跨卷用例({tag})\ + 按显式豁免跳过——跨卷逻辑此刻只有 install_io_cases 的注入用例在守。" + ); + None + } + Err(message) => panic!("{message}"), + } +} + +/// **常跑的裁决用例**:跨卷守卫要么在真跑,要么有记录在案的豁免,要么红。 +/// +/// 它自己不需要第二块卷也能跑,所以永远被执行;`--nocapture` 下能看到走的是哪一档。 +#[test] +fn the_cross_volume_guard_is_live_or_explicitly_waived() { + let root = XdevRoot::probe("verdict"); + let waiver = std::env::var(XDEV_WAIVE_ENV).ok(); + let require = std::env::var(XDEV_REQUIRE_ENV).ok(); + match xdev_guard(root.is_some(), waiver.as_deref(), require.as_deref()) { + Ok(XdevGuard::Live) => { + let at = root.as_ref().map(|root| root.path().display().to_string()); + eprintln!( + "[xdev-guard] 探到第二块可写卷({}):真跨卷用例会真跑。", + at.unwrap_or_default() + ); + } + Ok(XdevGuard::Waived) => { + eprintln!( + "[xdev-guard] 本机没有第二块可写卷,但已显式豁免({XDEV_WAIVE_ENV}=1):\ + 真跨卷用例跳过,跨卷逻辑由 install_io_cases 的注入用例守着。" + ); + } + Err(message) => panic!("{message}"), + } +} + +/// 裁决表的每一格都得钉住——尤其「既没探到也没豁免 ⇒ 红」这一格(P2-1 的回归点)。 +#[test] +fn the_verdict_table_has_exactly_three_ways() { + // 探到了:永远是 Live(豁免给了也不影响,真跨卷用例照跑)。 + for (waiver, require) in [(None, None), (Some("1"), None), (None, Some("1"))] { + assert_eq!( + xdev_guard(true, waiver, require), + Ok(XdevGuard::Live), + "探到了就该真跑:{waiver:?}/{require:?}" + ); + } + // 探不到 + 显式豁免 ⇒ Waived(记录在案的降级)。 + assert_eq!(xdev_guard(false, Some("1"), None), Ok(XdevGuard::Waived)); + assert_eq!( + xdev_guard(false, Some(" 1 "), None), + Ok(XdevGuard::Waived), + "两侧空白应忽略" + ); + // 探不到 + 没豁免 ⇒ 红(这条就是 P2-1 要的那个「响」)。 + let error = xdev_guard(false, None, None).expect_err("没豁免就必须红"); + assert!(error.contains("空跑"), "失败正文要说清是空跑:{error}"); + // 只认 `1`。 + for bogus in [Some("true"), Some("yes"), Some(""), Some("0")] { + assert!( + xdev_guard(false, bogus, None).is_err(), + "只有 `1` 算豁免,{bogus:?} 不算" + ); + } + // 声明「必须有」⇒ 探不到就红,且 REQUIRE 压过 WAIVE(不许自相矛盾地放行)。 + let error = xdev_guard(false, None, Some("1")).expect_err("声明了必须有就必须红"); + assert!(error.contains("REQUIRE"), "{error}"); + let error = xdev_guard(false, Some("1"), Some("1")).expect_err("矛盾时按更严的办"); + assert!(error.contains("自相矛盾"), "{error}"); +} + +/// 在 `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) = cross_volume_scene("install") else { + 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..2e0bf78 100644 --- a/crates/core/src/update/tests/mod.rs +++ b/crates/core/src/update/tests/mod.rs @@ -6,7 +6,10 @@ //! * 清单校验:重复版本、零长度、缺摘要/摘要格式错、未知平台与格式; //! * 渠道准入:阶梯比较 + 预发布阶段表; //! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留; -//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用; +//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用、 +//! 跨卷(暂存在 A 卷、目标在 B 卷)安装成功且字节完整、中途失败不留半截文件、 +//! 同卷不多搬一个字节、旧备份只留最近一份(崩溃现场的唯一旧副本例外)、 +//! 跨卷守卫「探不到第二块卷」时不许静默变绿(裁决用例见 `install_xdev_cases`); //! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、 //! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来, //! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给); @@ -19,6 +22,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..a85e8f2 100644 --- a/crates/core/src/update/tests/support.rs +++ b/crates/core/src/update/tests/support.rs @@ -54,6 +54,135 @@ 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"; + +/// 「我知道本机没有第二块卷」的**显式豁免**:值必须是 `1`(`true`/`yes` 不算,手误不该放行)。 +/// +/// 没有它、又探不到第二块卷 ⇒ 跨卷守卫**当场红**(见 `install_xdev_cases::xdev_guard`)。 +/// 它不会让真跨卷用例变假绿:探到了第二块卷照样真跑(豁免只在探测失败时被咨询)。 +pub(crate) const XDEV_WAIVE_ENV: &str = "ULL_XDEV_TEST_WAIVE"; + +/// 「本机/本作业**必须**有第二块卷」的显式声明:值必须是 `1`(与豁免同给时以它为准,不许自相矛盾)。 +pub(crate) const XDEV_REQUIRE_ENV: &str = "ULL_XDEV_TEST_REQUIRE"; + +/// 一块**与会话临时目录不同卷**的可写目录:跨卷替换用例的现场。 +/// +/// 「不同卷」不是按平台猜的,是**探测**出来的:往候选目录 rename 一个探针文件,只有真的报 +/// `CrossesDevices`(Unix `EXDEV` / Windows `ERROR_NOT_SAME_DEVICE`)才认这块地——否则 +/// 用例会在同一卷上跑,等于什么都没验。 +/// +/// 候选顺序:`ULL_XDEV_TEST_ROOT` → 各盘符(Windows:`<盘>:\Temp`,没有就盘根)/ +/// `TMPDIR`、`/dev/shm`、`/run/user/`、`/tmp`(Unix)。 +/// +/// 探不到时**怎么处置不是这里决定的**:本函数只回答「有没有」,跳过/失败由 +/// `install_xdev_cases::xdev_guard` 裁决(跳过必须是响的)。 +pub(crate) struct XdevRoot { + path: PathBuf, +} + +impl XdevRoot { + /// 探一块异卷目录;探不到返回 `None`(是否允许跳过由 `xdev_guard` 定)。 + 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 path(&self) -> &Path { + &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)