From ef77c67d3c9b9a3091ef79731c014f15c653c0d9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 8 Nov 2026 13:34:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(m171):=20r2=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=B7=A8=E5=8D=B7=E5=AE=88=E5=8D=AB=E4=B8=8D=E8=AE=B8=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E8=B7=B3=E8=BF=87=EF=BC=8C=E5=B4=A9=E6=BA=83=E7=8E=B0?= =?UTF-8?q?=E5=9C=BA=E4=B8=8D=E5=88=A0=E5=94=AF=E4=B8=80=E6=97=A7=E5=89=AF?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按首轮复审 p2-1items 的三条就地修。 P2-1(唯一阻塞项)跨卷守卫在只有一块卷的机器上静默变 ok:探不到第二块卷时 是 `eprintln! + return`,libtest 默认捕获 stdout/stderr,于是默认输出只有 `... ok`,跳过的记录只有 `--nocapture` 才看得见(复审 M3 变异实测)。 按复审给的方案 (b) 把裁决收成一条常跑用例(本仓口径同 `options_shot/feed.rs::not_fed`:跳过必须是响的): 探到第二块卷 → 绿(真跨卷用例真跑) 探不到 + ULL_XDEV_TEST_WAIVE=1 → 绿(显式、记录在案的降级,打印一行) 探不到 + 没豁免 → 红(默认判红,失败正文给出三条出路) 另有 ULL_XDEV_TEST_REQUIRE=1:声明「必须有」,与豁免同给时以它为准(不许自相矛盾) 裁决是纯函数 `xdev_guard(found, waiver, require)`:逐条用例走同一个入口 (`cross_volume_scene`),没人能靠 `return` 静静跳过;core 与 CLI e2e 两处政策一致 (源头在 `install_xdev_cases`,CLI 侧 `support::cross_volume_waived` 照抄并注明)。 CI 选择:**windows 作业显式声明豁免**(不挂 VHD)。理由:VHD 要多一步 diskpart、 要管盘符、易碎;豁免是一行 env、写在 ci.yml 里可见,且只在探测失败时被咨询—— 将来 runner 真有第二块盘,真跨卷用例照样真跑。ubuntu 侧不豁免:`/tmp` 与 `/dev/shm` 是不同设备(本机 docker debian:bookworm-slim 实测设备号 119 / 158), 真跨卷守卫在 Linux 上应当是真跑的。ci.yml 不在本 mission 的改动范围,已用 TowerFinding 登记那一行补丁。 P3-1 `update_e2e_test.rs` 里 `install::prune_stale_backups` → `prune_stale_artifacts`。 P3-2 `prune_stale_artifacts(None, dest)` 会删掉崩溃现场唯一幸存的旧 exe:上一次装到 「dest → .ullold-*」之后被杀时,dest 空着、旧字节只剩备份那一份;下次成功安装时 `symlink_metadata(dest)` 失败 ⇒ keep=None ⇒ 连它一起删,用户失去手工恢复的可能 (修前不会删)。改成 keep=None 时**只清 `.ullnew-*`、一个 `.ullold-*` 都不动**, 模块文档补上这条例外与其来由。 验证(本机,CARGO_TARGET_DIR=C:/Temp/ull-target/wt-171): * `cargo test -p unlucky-core --lib update::tests` → 90 passed / 0 failed * `cargo test -p unlucky-core` → 623 passed(+ 各集成目标全绿),exit 0 * `cargo clippy -p unlucky-core --all-targets` / `-p unlucky-cli --all-targets --features update-test-source` → exit 0 * `cargo test -p unlucky-cli --features update-test-source --test update_e2e_test --test update_e2e_tamper_test` → 3 + 4 passed,含 `installs_across_volumes_from_the_cli` * fmt clean 变异实测(都验完再还原): M3 让 probe 恒 None ⇒ 默认 `cargo test` 输出 2 failed(无须 --nocapture), 正文「没有第二块可写卷,也没有显式豁免:跨卷守卫在这台机器上是空跑。」 M3 + WAIVE=1 ⇒ 3 passed;M3 + REQUIRE=1 ⇒ 2 failed 去掉 keep.is_some() 那一半 ⇒ `a_crashed_install_keeps_the_only_surviving_old_copy` 红 --- crates/cli/tests/support/mod.rs | 32 +++ crates/cli/tests/update_e2e_test.rs | 14 +- crates/core/src/update/install.rs | 24 ++- crates/core/src/update/tests/install_cases.rs | 40 ++++ .../src/update/tests/install_xdev_cases.rs | 193 ++++++++++++++++-- crates/core/src/update/tests/mod.rs | 3 +- crates/core/src/update/tests/support.rs | 19 +- 7 files changed, 299 insertions(+), 26 deletions(-) diff --git a/crates/cli/tests/support/mod.rs b/crates/cli/tests/support/mod.rs index 08a2c1d..f2596da 100644 --- a/crates/cli/tests/support/mod.rs +++ b/crates/cli/tests/support/mod.rs @@ -59,6 +59,38 @@ 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`、 diff --git a/crates/cli/tests/update_e2e_test.rs b/crates/cli/tests/update_e2e_test.rs index 158cd5a..ce342e2 100644 --- a/crates/cli/tests/update_e2e_test.rs +++ b/crates/cli/tests/update_e2e_test.rs @@ -35,7 +35,7 @@ //! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 | //! | 重复 `apply` 后目标字节稳定、暂存无残留、只留一份备份 | 「第二次把 dest 写坏 / 暂存留下垃圾 / 备份无限堆积」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe | //! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷失败路径:本文件只验了跨卷**成功**那一半(见下一行);跨卷下的中途失败由 `install_io_cases` 注入验 | -//! | 跨卷 `apply`(数据根一卷、安装目录另一卷,M171) | 「rename 不能跨卷 ⇒ 更新必然失败」这条已发布版本上的真 bug;用例自己先证明前提(rename 报 `CrossesDevices`) | 只有一块可写卷的机器/CI 上**跳过**(明文打印);macOS 的跨卷行为一台没跑过 | +//! | 跨卷 `apply`(数据根一卷、安装目录另一卷,M171) | 「rename 不能跨卷 ⇒ 更新必然失败」这条已发布版本上的真 bug;用例自己先证明前提(rename 报 `CrossesDevices`) | 只有一块可写卷的机器上**只有显式豁免才允许跳过**(`ULL_XDEV_TEST_WAIVE=1`,探不到又没豁免 ⇒ 红);macOS 的跨卷行为一台没跑过 | //! | 失败路径三例(`update_e2e_tamper_test`:篡改清单 sha / 篡改包体 / 截断) | 「校验判据被绕过」「失败却已动过目标」「失败留下 `.ullupd`」 | **回滚与备份**:摘要/长度失败发生在 `install_staged` **之前**,那一步根本走不到——回滚靠正路的备份断言 + `install_cases` 单测(r1 复审 ⑤ 的变异实测:去掉 dest 备份后失败路径三例仍全绿,只有正路/幂等变红) | //! //! # 不验的部分(如实划界) @@ -167,7 +167,7 @@ fn an_empty_local_channel_is_up_to_date_not_an_error() { /// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾、备份只有一份。 /// /// 如实记两条:`apply` 每次都留一份备份(CLI 从不删,等下一次启动/人工清理),但**只留最近一份** -/// (`install::prune_stale_backups`,M171);第二次之所以还会「装上」,是因为 `current` 是编译期 +/// (`install::prune_stale_artifacts`,M171);第二次之所以还会「装上」,是因为 `current` 是编译期 /// 版本——重复执行不是空操作,只是结果幂等。 #[test] fn applying_twice_leaves_the_same_target_bytes() { @@ -207,17 +207,15 @@ fn applying_twice_leaves_the_same_target_bytes() { /// 而 `rename` 不能跨卷。修法是先落到**目标同目录**的中转名(同卷改名)再顶上去。 /// /// 用例先自己证明**前提成立**(从数据根到安装目录 rename 必须报 `CrossesDevices`),否则 -/// 「跨卷成功」这句话就是假的。本机只有一块可写卷时**跳过**(明文打印原因)——跨卷逻辑本身由 -/// `crates/core` 的 `install_xdev_cases`(真跨卷)与 `install_io_cases`(注入)守着。 +/// 「跨卷成功」这句话就是假的。本机只有一块可写卷时**只有显式豁免才允许跳过** +/// (`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 { - eprintln!( - "跳过跨卷 e2e:本机找不到第二块可写卷(可用 ULL_XDEV_TEST_ROOT 指一块,\ - 例如 C:/Temp + D:/Temp)" - ); + support::cross_volume_waived("cli-e2e"); return; }; Channel::enable(fx.dist.path()); diff --git a/crates/core/src/update/install.rs b/crates/core/src/update/install.rs index 4d6de20..f8c492e 100644 --- a/crates/core/src/update/install.rs +++ b/crates/core/src/update/install.rs @@ -41,6 +41,11 @@ //! [`InstallReceipt::remove_backup`],等于每更新一次就在安装目录里堆一个 20+ MB 的 exe。 //! 「留多份」唯一能多给的东西是「回退到更早的版本」,而回滚只用最近一份,所以按份数上界收敛成 1。 //! +//! **一条刻意的例外**:本次安装前 `dest` 就不存在时(`keep = None`)一个 `.ullold-*` 都不删, +//! 只清残留的 `.ullnew-*`。理由见 `prune_stale_artifacts`:那个时刻的旧备份可能是崩溃现场里 +//! **唯一幸存的旧 exe**(上一次装在「dest → 备份」之后、「中转名 → dest」之前被杀), +//! 删掉它等于抹掉用户手工恢复的最后一份材料。 +//! //! # 本模块**不**负责的事 //! //! * **不判断现在能不能替换**:占用/杀软/权限都在 [`ReplacePolicy`] 的重试里撞,撞不动就回滚。 @@ -316,11 +321,22 @@ fn new_path(dest: &Path) -> PathBuf { tagged_path(dest, NEW_TAG) } -/// 只留最近一份旧版本备份:删掉同目录里更早的 `..ullold-*` 与残留的 `.ullnew-*`。 +/// 清同目录里的替换残留:只留最近一份旧版本备份,顺带扫掉更早失败遗留的中转名。 +/// +/// * `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`)不会被动。`.ullnew-*` 全是垃圾:本次那份在调用前已经被改名成 dest 了, -/// 还留着的只可能是更早某次失败遗留的中转文件(本次失败路径自己会删,删不掉才会留到这里)。 +/// 那一份(`keep`)不会被动。 /// /// 前提是「同一个 exe 只有一个更新者在动」。两个进程同时替换同一路径本来就不成立: /// 后一个回滚会把前一个刚装上的新版本换成旧字节。这条不因为保留多份而变好,反倒因为 @@ -342,7 +358,7 @@ fn prune_stale_artifacts(keep: Option<&Path>, dest: &Path) { } 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) + other.starts_with(&new_prefix) || (keep.is_some() && other.starts_with(&old_prefix)) }); if stale { let _ = fs::remove_file(&path); diff --git a/crates/core/src/update/tests/install_cases.rs b/crates/core/src/update/tests/install_cases.rs index 6fa153e..7094028 100644 --- a/crates/core/src/update/tests/install_cases.rs +++ b/crates/core/src/update/tests/install_cases.rs @@ -217,3 +217,43 @@ fn only_the_latest_backup_is_kept() { "只该留本次备份,且不许碰别的程序的备份" ); } + +/// 崩溃现场:上一次装到「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_xdev_cases.rs b/crates/core/src/update/tests/install_xdev_cases.rs index 4c2b6b3..dcb0391 100644 --- a/crates/core/src/update/tests/install_xdev_cases.rs +++ b/crates/core/src/update/tests/install_xdev_cases.rs @@ -1,4 +1,5 @@ -//! 跨卷替换:暂存包在 A 卷、目标 exe 在 B 卷时,替换必须成功且**不留半截文件**。 +//! 跨卷替换:暂存包在 A 卷、目标 exe 在 B 卷时,替换必须成功且**不留半截文件**, +//! 并且「凑不出跨卷现场」这件事**必须是响的**。 //! //! # 这条用例为什么必须存在 //! @@ -8,20 +9,51 @@ //! 在已发布版本上判定「更新必然失败」。修法是**先把包落到目标同目录的临时名,再同卷改名**: //! 目标路径上的新字节永远来自一次原子命名,不存在「拷到一半」的中间态。 //! -//! # 跨卷是探出来的,不是假设出来的 +//! # 跨卷是探出来的,探不到**不许静默变绿**(M171 r2 P2-1) //! -//! [`XdevRoot::probe`] 用一次真实 `rename` 验证候选目录**真的**在另一块卷上;找不到就跳过并 -//! 打一行说明(`cargo test -- --nocapture` 可见)。所以: +//! [`XdevRoot::probe`] 用一次真实 `rename` 验证候选目录**真的**在另一块卷上。问题是「探不到」 +//! 以前写成 `eprintln! + return`:libtest 默认捕获 stdout/stderr,于是这台机器只有一块卷时 +//! 用例显示 `... ok`,跨卷守卫**空跑了却看不出来**(复审 M3 变异实测)。本仓已确立的口径是 +//! 「跳过这件事必须是响的」(同 `crates/app-iced/.../options_shot/feed.rs::not_fed`), +//! 所以裁决被收进 [`xdev_guard`],三档: //! -//! * 有第二块卷的机器(本仓开发机:`C:` NVMe + `D:` 机械盘)→ 真跨卷; -//! * 只有一块卷的机器 / CI → 跳过,这条守卫**不生效**(同卷覆盖靠 `install_cases` 与注入用例)。 +//! | 探到第二块卷 | `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}; +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"; @@ -30,6 +62,147 @@ 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); @@ -53,11 +226,7 @@ fn staged_on_a_volume(tmp: &TempDir) -> StagedPackage { /// ① 跨卷安装必须成功,且目标字节 = 包体字节(不是半截、不是旧字节)。 #[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)" - ); + let Some(xdev) = cross_volume_scene("install") else { return; }; let tmp = TempDir::new("xdev-install"); diff --git a/crates/core/src/update/tests/mod.rs b/crates/core/src/update/tests/mod.rs index a2a5756..2e0bf78 100644 --- a/crates/core/src/update/tests/mod.rs +++ b/crates/core/src/update/tests/mod.rs @@ -8,7 +8,8 @@ //! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留; //! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用、 //! 跨卷(暂存在 A 卷、目标在 B 卷)安装成功且字节完整、中途失败不留半截文件、 -//! 同卷不多搬一个字节、旧备份只留最近一份; +//! 同卷不多搬一个字节、旧备份只留最近一份(崩溃现场的唯一旧副本例外)、 +//! 跨卷守卫「探不到第二块卷」时不许静默变绿(裁决用例见 `install_xdev_cases`); //! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、 //! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来, //! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给); diff --git a/crates/core/src/update/tests/support.rs b/crates/core/src/update/tests/support.rs index 746bbd7..a85e8f2 100644 --- a/crates/core/src/update/tests/support.rs +++ b/crates/core/src/update/tests/support.rs @@ -72,6 +72,15 @@ pub(crate) fn names_in(dir: &Path) -> Vec { /// 指定异卷根目录的环境变量(跨卷夹具找不到别的卷时,用它显式指一块)。 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 一个探针文件,只有真的报 @@ -80,12 +89,15 @@ pub(crate) const XDEV_ROOT_ENV: &str = "ULL_XDEV_TEST_ROOT"; /// /// 候选顺序:`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`(调用方**跳过**并在输出里说明原因)。 + /// 探一块异卷目录;探不到返回 `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) { @@ -101,6 +113,11 @@ impl XdevRoot { }) } + /// 现场根目录。 + pub(crate) fn path(&self) -> &Path { + &self.path + } + /// 现场根目录下的子目录(自动建)。 pub(crate) fn dir(&self, name: &str) -> PathBuf { let path = self.path.join(name);