fix(m171): 跨卷替换改成「目标同目录中转名 + 同卷原子改名」
已发布版本上的真 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-<pid>-<seq> 同卷 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)
This commit is contained in:
1 parent
ab79a28b28
commit
36ff193609
11 files changed
+1007
-119
No files matched your search
@@ -17,17 +17,26 @@ pub struct TempDir {
|
|||||||
impl TempDir {
|
impl TempDir {
|
||||||
/// 建一个唯一目录。
|
/// 建一个唯一目录。
|
||||||
pub fn new(tag: &str) -> Self {
|
pub fn new(tag: &str) -> Self {
|
||||||
|
Self::in_root(&std::env::temp_dir(), tag).expect("创建临时目录")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 建在**另一块卷**上(跨卷验收用);本机只有一块可写卷时返回 `None`。
|
||||||
|
pub fn on_other_volume(tag: &str) -> Option<Self> {
|
||||||
|
other_volume_root().and_then(|root| Self::in_root(&root, tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_root(root: &Path, tag: &str) -> Option<Self> {
|
||||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
let nanos = SystemTime::now()
|
let nanos = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map_or(0, |duration| duration.as_nanos());
|
.map_or(0, |duration| duration.as_nanos());
|
||||||
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
|
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}",
|
"ull-cli-test-{tag}-{}-{seq}-{nanos}",
|
||||||
std::process::id()
|
std::process::id()
|
||||||
));
|
));
|
||||||
std::fs::create_dir_all(&path).expect("创建临时目录");
|
std::fs::create_dir_all(&path).ok()?;
|
||||||
Self { path }
|
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/<uid>`。每个候选都要过「从 temp 目录 rename 进它必须报
|
||||||
|
/// `CrossesDevices`」这一关,所以返回值**一定**是异卷——同一块卷上的候选会被跳过,
|
||||||
|
/// 否则跨卷用例会在同卷上跑,等于什么都没验。
|
||||||
|
pub fn other_volume_root() -> Option<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
#[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<PathBuf> = ["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)。
|
/// 跑一次 CLI,返回(退出码, stdout, stderr)。
|
||||||
pub fn run_cli(args: &[&str]) -> (i32, String, String) {
|
pub fn run_cli(args: &[&str]) -> (i32, String, String) {
|
||||||
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
|
let owned: Vec<String> = args.iter().map(|arg| arg.to_string()).collect();
|
||||||
|
|||||||
@@ -222,9 +222,21 @@ fn payload_bytes() -> Vec<u8> {
|
|||||||
|
|
||||||
impl Fixture {
|
impl Fixture {
|
||||||
pub fn new(tag: &str) -> Self {
|
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<Self> {
|
||||||
|
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 payload = payload_bytes();
|
||||||
let version = next_version(env!("CARGO_PKG_VERSION"));
|
let version = next_version(env!("CARGO_PKG_VERSION"));
|
||||||
let install = super::TempDir::new(&format!("{tag}-install"));
|
|
||||||
let dest = install.path().join("unlucky-launcher.exe");
|
let dest = install.path().join("unlucky-launcher.exe");
|
||||||
std::fs::write(&dest, INSTALLED).expect("写旧版本");
|
std::fs::write(&dest, INSTALLED).expect("写旧版本");
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -33,8 +33,9 @@
|
|||||||
//! | 清单 → 选版本 → 下载 → 双检 → 就位整环跑通 | 任一环断了(字段拼错、渠道不符、没下、没校验、没搬)都会红 | 真 GitHub Release 的下载链路(本套一个字节不出网) |
|
//! | 清单 → 选版本 → 下载 → 双检 → 就位整环跑通 | 任一环断了(字段拼错、渠道不符、没下、没校验、没搬)都会红 | 真 GitHub Release 的下载链路(本套一个字节不出网) |
|
||||||
//! | 清单 `sha256` 与盘上字节不符 ⇒ 拒绝 + 目标不动 | 「校验和判据被绕过/写成恒真」;篡改包体或篡改清单都能抓(见 `update_e2e_tamper_test`) | 「CDN 换了内容**而清单里的 sha 也一起换**」——这条路上没有可信根,只能靠 GitHub 侧 |
|
//! | 清单 `sha256` 与盘上字节不符 ⇒ 拒绝 + 目标不动 | 「校验和判据被绕过/写成恒真」;篡改包体或篡改清单都能抓(见 `update_e2e_tamper_test`) | 「CDN 换了内容**而清单里的 sha 也一起换**」——这条路上没有可信根,只能靠 GitHub 侧 |
|
||||||
//! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 |
|
//! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 |
|
||||||
//! | 重复 `apply` 后目标字节稳定、暂存无残留 | 「第二次把 dest 写坏 / 暂存留下垃圾」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe |
|
//! | 重复 `apply` 后目标字节稳定、暂存无残留、只留一份备份 | 「第二次把 dest 写坏 / 暂存留下垃圾 / 备份无限堆积」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe |
|
||||||
//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷(数据根与 exe 不同盘)时的替换:`rename` 不能跨卷,那条路没验 |
|
//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷失败路径:本文件只验了跨卷**成功**那一半(见下一行);跨卷下的中途失败由 `install_io_cases` 注入验 |
|
||||||
|
//! | 跨卷 `apply`(数据根一卷、安装目录另一卷,M171) | 「rename 不能跨卷 ⇒ 更新必然失败」这条已发布版本上的真 bug;用例自己先证明前提(rename 报 `CrossesDevices`) | 只有一块可写卷的机器/CI 上**跳过**(明文打印);macOS 的跨卷行为一台没跑过 |
|
||||||
//! | 失败路径三例(`update_e2e_tamper_test`:篡改清单 sha / 篡改包体 / 截断) | 「校验判据被绕过」「失败却已动过目标」「失败留下 `.ullupd`」 | **回滚与备份**:摘要/长度失败发生在 `install_staged` **之前**,那一步根本走不到——回滚靠正路的备份断言 + `install_cases` 单测(r1 复审 ⑤ 的变异实测:去掉 dest 备份后失败路径三例仍全绿,只有正路/幂等变红) |
|
//! | 失败路径三例(`update_e2e_tamper_test`:篡改清单 sha / 篡改包体 / 截断) | 「校验判据被绕过」「失败却已动过目标」「失败留下 `.ullupd`」 | **回滚与备份**:摘要/长度失败发生在 `install_staged` **之前**,那一步根本走不到——回滚靠正路的备份断言 + `install_cases` 单测(r1 复审 ⑤ 的变异实测:去掉 dest 备份后失败路径三例仍全绿,只有正路/幂等变红) |
|
||||||
//!
|
//!
|
||||||
//! # 不验的部分(如实划界)
|
//! # 不验的部分(如实划界)
|
||||||
@@ -47,6 +48,9 @@
|
|||||||
//! (`update_session_tests.rs`),CLI 侧没有对应开关,所以这里不覆盖。
|
//! (`update_session_tests.rs`),CLI 侧没有对应开关,所以这里不覆盖。
|
||||||
//! 4. **`--dest` 之外的自替换**(替换本进程 exe):`tests/support/update_e2e.rs` 的文件头写了为什么
|
//! 4. **`--dest` 之外的自替换**(替换本进程 exe):`tests/support/update_e2e.rs` 的文件头写了为什么
|
||||||
//! 这几条用例永远带 `--dest`,以及「去掉它会真的改名换掉测试二进制」这件事。
|
//! 这几条用例永远带 `--dest`,以及「去掉它会真的改名换掉测试二进制」这件事。
|
||||||
|
//! 5. **macOS / Linux 上的跨卷**:跨卷用例只在**本机 Windows**(C: NVMe + D: 机械盘)上真跑过;
|
||||||
|
//! macOS 的卷(APFS 宗卷之间 `/Volumes` 挂载)与 Linux 的 tmpfs↔ext4 一次都没跑——两边的
|
||||||
|
//! `EXDEV` 语义相同,但「同目录中转 + 改名」在同一台机器上有没有别的坑,本套没有证据。
|
||||||
|
|
||||||
#![cfg(feature = "update-test-source")]
|
#![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);
|
assert_eq!(data["test_source"], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾。
|
/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾、备份只有一份。
|
||||||
///
|
///
|
||||||
/// 如实记两条:`apply` 每次都留一个备份(CLI 从不删,等下一次启动/人工清理),而且第二次之所以
|
/// 如实记两条:`apply` 每次都留一份备份(CLI 从不删,等下一次启动/人工清理),但**只留最近一份**
|
||||||
/// 还会「装上」,是因为 `current` 是编译期版本——重复执行不是空操作,只是结果幂等。
|
/// (`install::prune_stale_backups`,M171);第二次之所以还会「装上」,是因为 `current` 是编译期
|
||||||
|
/// 版本——重复执行不是空操作,只是结果幂等。
|
||||||
#[test]
|
#[test]
|
||||||
fn applying_twice_leaves_the_same_target_bytes() {
|
fn applying_twice_leaves_the_same_target_bytes() {
|
||||||
let _serial = channel_lock();
|
let _serial = channel_lock();
|
||||||
@@ -189,8 +194,68 @@ fn applying_twice_leaves_the_same_target_bytes() {
|
|||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fx.backups().len(),
|
fx.backups().len(),
|
||||||
2,
|
1,
|
||||||
"每次 apply 留一个备份(CLI 不删):{:?}",
|
"只留最近一份备份(每次 apply 建一份、成功即清更早的):{:?}",
|
||||||
fx.backups()
|
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,
|
||||||
|
"旧字节必须完整留在备份里"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
//! 原子替换:先把旧文件改名挪开,再把新文件搬进来;任一步失败都能回滚。
|
//! 原子替换:先把新包落到**目标同目录**的中转名,再把旧文件改名挪开、中转名顶上;
|
||||||
|
//! 任一步失败都能回滚,**目标路径上永远不会出现半截文件**。
|
||||||
//!
|
//!
|
||||||
//! # 为什么不用「直接覆盖」
|
//! # 为什么不用「直接覆盖」
|
||||||
//!
|
//!
|
||||||
@@ -8,16 +9,38 @@
|
|||||||
//! 把旧文件挪到同目录的备份名,让新文件顶替原来的路径。而改名本身也可能被拒绝(杀软扫描
|
//! 把旧文件挪到同目录的备份名,让新文件顶替原来的路径。而改名本身也可能被拒绝(杀软扫描
|
||||||
//! 句柄、编辑器打开、另一个进程正在写),所以失败必须能回到旧版本——这正是本模块存在的理由。
|
//! 句柄、编辑器打开、另一个进程正在写),所以失败必须能回到旧版本——这正是本模块存在的理由。
|
||||||
//!
|
//!
|
||||||
//! 三步写法,Unix 上也照这个顺序跑(Unix 本可直接 `rename` 覆盖,但两套语义分叉之后,
|
//! # 为什么先落中转名(M171 跨卷修复)
|
||||||
//! 回滚路径只有 Windows 被真正跑过,最容易坏的那一半就没人测了):
|
//!
|
||||||
|
//! 暂存包在 `<数据根>/cache/update/staging`,目标 exe 在安装目录:**两者可能不在同一卷**
|
||||||
|
//! (便携版放 U 盘、数据根在 D 盘而程序在 C 盘、部分 macOS 配置)。`rename` 不能跨卷
|
||||||
|
//! (Unix `EXDEV` / Windows `ERROR_NOT_SAME_DEVICE`,`std` 不会偷偷降级成复制),
|
||||||
|
//! 于是「暂存包 → 目标」那一步必然失败——M169 复审判定这是已发布版本上的真实 bug。
|
||||||
|
//!
|
||||||
|
//! 修法不是「把 rename 换成 copy」(那会让目标路径上出现半截文件),而是把**原子替换的前提**
|
||||||
|
//! 变成不依赖跨卷:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! ```text
|
||||||
//! 1. dest 存在 → rename(dest → <同目录备份>) 旧版本先安全落地
|
//! 0. 新包 → <目标同目录>/.<文件名>.ullnew-<pid>-<seq> 同卷改名;跨卷时复制到这儿并核对字节数
|
||||||
//! 2. rename(暂存包 → dest) 新版本就位
|
//! 1. dest 存在 → rename(dest → <同目录 .ullold-…>) 旧版本先安全落地
|
||||||
|
//! 2. rename(中转名 → dest) 新版本就位(同卷,原子)
|
||||||
//! 3a. 成功 → 返回 InstallReceipt(备份留着,等新版真的能起来再删)
|
//! 3a. 成功 → 返回 InstallReceipt(备份留着,等新版真的能起来再删)
|
||||||
//! 3b. 失败 → rename(备份 → dest) 回滚,并把「回滚是否成功」如实报给调用方
|
//! 3b. 失败 → rename(备份 → dest) 回滚,并把「回滚是否成功」如实报给调用方
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
//! 关键性质:**第 2 步之后目标路径要么是旧字节要么是新字节**,不可能是一半。跨卷那条路多出来的
|
||||||
|
//! 复制只发生在第 0 步,落的是中转名:复制到一半失败 → 删中转名、目标一个字节都没动
|
||||||
|
//! (多数情况下备份都还没建)。同一卷上走的是改名不是复制,**没有一个字节被多搬一次**。
|
||||||
|
//!
|
||||||
|
//! Unix 上也照这个顺序跑(Unix 本可直接 `rename` 覆盖,但两套语义分叉之后,回滚路径只有 Windows
|
||||||
|
//! 被真正跑过,最容易坏的那一半就没人测了)。
|
||||||
|
//!
|
||||||
|
//! # 旧版本备份留几份
|
||||||
|
//!
|
||||||
|
//! 替换成功的那一刻会把**同目录里更早的** `.<同名文件>.ullold-*` 删掉,只留本次这一份
|
||||||
|
//! (见 `prune_stale_artifacts`):旧实现在每次 apply 都留一份、而全仓**没有任何调用方**调
|
||||||
|
//! [`InstallReceipt::remove_backup`],等于每更新一次就在安装目录里堆一个 20+ MB 的 exe。
|
||||||
|
//! 「留多份」唯一能多给的东西是「回退到更早的版本」,而回滚只用最近一份,所以按份数上界收敛成 1。
|
||||||
|
//!
|
||||||
//! # 本模块**不**负责的事
|
//! # 本模块**不**负责的事
|
||||||
//!
|
//!
|
||||||
//! * **不判断现在能不能替换**:占用/杀软/权限都在 [`ReplacePolicy`] 的重试里撞,撞不动就回滚。
|
//! * **不判断现在能不能替换**:占用/杀软/权限都在 [`ReplacePolicy`] 的重试里撞,撞不动就回滚。
|
||||||
@@ -27,10 +50,10 @@
|
|||||||
//! (改字节这条路根本不存在)。这也是本模块「先改名、再搬入」而不是「直接覆盖」的原因。
|
//! (改字节这条路根本不存在)。这也是本模块「先改名、再搬入」而不是「直接覆盖」的原因。
|
||||||
//! 别照早先那版文档去设计「退出自身 → 由另一个进程搬运」的双进程方案(HMCL 的 `--apply-to`):
|
//! 别照早先那版文档去设计「退出自身 → 由另一个进程搬运」的双进程方案(HMCL 的 `--apply-to`):
|
||||||
//! 那条路在 Windows 上不是必需的。(Unix 上 `rename` 本就可以覆盖,两步写法是为了两边同一条
|
//! 那条路在 Windows 上不是必需的。(Unix 上 `rename` 本就可以覆盖,两步写法是为了两边同一条
|
||||||
//! 回滚路径,见下。)
|
//! 回滚路径,见上。)
|
||||||
//! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器,
|
//! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器,
|
||||||
//! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。
|
//! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。
|
||||||
//! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再
|
//! * **不重启、不删备份**(最新一份何时删由上层决定:新版启动成功后
|
||||||
//! [`InstallReceipt::remove_backup`])。
|
//! [`InstallReceipt::remove_backup`])。
|
||||||
//!
|
//!
|
||||||
//! # 单测覆盖到哪
|
//! # 单测覆盖到哪
|
||||||
@@ -40,51 +63,31 @@
|
|||||||
//! 让 `rename` 撞共享冲突,于是在 Windows 上能看到「重试 → 仍失败 → 旧版本完好」。
|
//! 让 `rename` 撞共享冲突,于是在 Windows 上能看到「重试 → 仍失败 → 旧版本完好」。
|
||||||
//! 那条用例是 `#[cfg(windows)]`:Unix 允许改名打开中的文件,本来就该成功。
|
//! 那条用例是 `#[cfg(windows)]`:Unix 允许改名打开中的文件,本来就该成功。
|
||||||
//!
|
//!
|
||||||
//! 但它不是「运行中的 exe」——那需要真的跑一个子进程并替换它,属真机验收,不在单测范围。
|
//! 跨卷场景是**真的跨卷**(`install_xdev_cases` 拿一次真实 rename 探出第二块卷),真盘上没法
|
||||||
|
//! 确定性复现的「跨卷被拒」「复制/改名到一半失败」则由 `install_io_cases` 的注入器打出来。
|
||||||
|
//! 它都不是「运行中的 exe」——那需要真的跑一个子进程并替换它,属真机验收,不在单测范围。
|
||||||
//! 另外注意:`std::fs::File::open` **默认共享删除**,用它造的「占用」不生效,
|
//! 另外注意:`std::fs::File::open` **默认共享删除**,用它造的「占用」不生效,
|
||||||
//! 写这类用例时必须显式指定 `share_mode`。
|
//! 写这类用例时必须显式指定 `share_mode`。
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use super::error::UpdateError;
|
use super::error::UpdateError;
|
||||||
|
use super::replace_io::{is_cross_device, rename_with_retry, RealFs, ReplaceFs};
|
||||||
use super::verify::StagedPackage;
|
use super::verify::StagedPackage;
|
||||||
|
|
||||||
/// 同进程内备份文件名去重序号。
|
pub use super::replace_io::ReplacePolicy;
|
||||||
|
|
||||||
|
/// 同进程内备份 / 中转文件名去重序号。
|
||||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
/// 替换策略:改名失败时重试几次、每次间隔多久。
|
/// 旧版本备份的名字标签(`.<文件名>.ullold-<pid>-<seq>`)。
|
||||||
///
|
const OLD_TAG: &str = "ullold";
|
||||||
/// 默认 5 次 × 80ms:足够扛过杀软扫描/索引器短暂占用,又不至于让用户在「更新」按钮上
|
|
||||||
/// 干等半分钟。真正长期的占用(另一个启动器实例在跑)重试多少次都没用,直接报错更诚实。
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct ReplacePolicy {
|
|
||||||
/// 总尝试次数(含首次),`0` 按 `1` 处理。
|
|
||||||
pub attempts: u32,
|
|
||||||
/// 两次尝试之间的等待。
|
|
||||||
pub delay: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ReplacePolicy {
|
/// 新版本中转名的名字标签(`.<文件名>.ullnew-<pid>-<seq>`)。
|
||||||
fn default() -> Self {
|
const NEW_TAG: &str = "ullnew";
|
||||||
Self {
|
|
||||||
attempts: 5,
|
|
||||||
delay: Duration::from_millis(80),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReplacePolicy {
|
|
||||||
/// 构造。
|
|
||||||
#[must_use]
|
|
||||||
pub const fn new(attempts: u32, delay: Duration) -> Self {
|
|
||||||
Self { attempts, delay }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 替换成功后的收据。
|
/// 替换成功后的收据。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -121,7 +124,7 @@ impl InstallReceipt {
|
|||||||
let Some(backup) = self.backup.as_ref() else {
|
let Some(backup) = self.backup.as_ref() else {
|
||||||
return Ok(());
|
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))?;
|
.map_err(|error| UpdateError::io_at(&self.dest, &error))?;
|
||||||
self.backup = None;
|
self.backup = None;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -167,12 +170,24 @@ impl std::error::Error for InstallFailure {}
|
|||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// 目录建不出来、目标被占用、改名失败都返回 [`InstallFailure`],
|
/// 目录建不出来、目标被占用、改名/跨卷复制失败都返回 [`InstallFailure`],
|
||||||
/// 其中的 `rolled_back` 如实反映旧版本有没有回到原位。
|
/// 其中的 `rolled_back` 如实反映旧版本有没有回到原位。
|
||||||
pub fn install_staged(
|
pub fn install_staged(
|
||||||
staged: &StagedPackage,
|
staged: &StagedPackage,
|
||||||
dest: &Path,
|
dest: &Path,
|
||||||
policy: ReplacePolicy,
|
policy: ReplacePolicy,
|
||||||
|
) -> Result<InstallReceipt, InstallFailure> {
|
||||||
|
install_with(&RealFs, staged, dest, policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`install_staged`] 的注入版:文件动作用调用方给的 [`ReplaceFs`]。
|
||||||
|
///
|
||||||
|
/// 判据与顺序与本函数完全相同——测试只能改「系统调用成不成功」,改不了「失败时目标是否完好」。
|
||||||
|
pub(crate) fn install_with(
|
||||||
|
fs: &dyn ReplaceFs,
|
||||||
|
staged: &StagedPackage,
|
||||||
|
dest: &Path,
|
||||||
|
policy: ReplacePolicy,
|
||||||
) -> Result<InstallReceipt, InstallFailure> {
|
) -> Result<InstallReceipt, InstallFailure> {
|
||||||
if let Some(parent) = dest
|
if let Some(parent) = dest
|
||||||
.parent()
|
.parent()
|
||||||
@@ -182,28 +197,44 @@ pub fn install_staged(
|
|||||||
.map_err(|error| failed(UpdateError::io_at(parent, &error), true, None))?;
|
.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;
|
let mut backup = None;
|
||||||
if fs::symlink_metadata(dest).is_ok() {
|
if fs::symlink_metadata(dest).is_ok() {
|
||||||
let path = backup_path(dest);
|
let path = backup_path(dest);
|
||||||
rename_with_retry(dest, &path, policy)
|
if let Err(error) = rename_with_retry(fs, dest, &path, policy) {
|
||||||
.map_err(|error| failed(UpdateError::io_at(dest, &error), true, None))?;
|
let _ = fs.remove(&new);
|
||||||
|
return Err(failed(UpdateError::io_at(dest, &error), true, None));
|
||||||
|
}
|
||||||
backup = Some(path);
|
backup = Some(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
match rename_with_retry(staged.path(), dest, policy) {
|
// 第 2 步:中转名 → dest(同卷,原子)。
|
||||||
Ok(()) => Ok(InstallReceipt {
|
match rename_with_retry(fs, &new, dest, policy) {
|
||||||
dest: dest.to_path_buf(),
|
Ok(()) => {
|
||||||
backup,
|
prune_stale_artifacts(backup.as_deref(), dest);
|
||||||
bytes: staged.size(),
|
Ok(InstallReceipt {
|
||||||
}),
|
dest: dest.to_path_buf(),
|
||||||
|
backup,
|
||||||
|
bytes: staged.size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
let _ = fs.remove(&new);
|
||||||
let error = UpdateError::io_at(dest, &error);
|
let error = UpdateError::io_at(dest, &error);
|
||||||
let Some(backup_path) = backup else {
|
let Some(backup_path) = backup else {
|
||||||
// 目标原本不存在:没有旧版本要恢复,目标也没被写坏,
|
// 目标原本不存在:没有旧版本要恢复,目标也没被写坏,
|
||||||
// 所以按「旧版本仍在原位」上报(新安装失败 ≠ 现有安装被破坏)。
|
// 所以按「旧版本仍在原位」上报(新安装失败 ≠ 现有安装被破坏)。
|
||||||
return Err(failed(error, true, None));
|
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)),
|
Ok(()) => Err(failed(error, true, None)),
|
||||||
Err(_) => Err(failed(error, false, Some(backup_path))),
|
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<PathBuf>) -> InstallFailure {
|
fn failed(error: UpdateError, rolled_back: bool, backup_left: Option<PathBuf>) -> InstallFailure {
|
||||||
InstallFailure {
|
InstallFailure {
|
||||||
error,
|
error,
|
||||||
@@ -219,6 +285,19 @@ fn failed(error: UpdateError, rolled_back: bool, backup_left: Option<PathBuf>) -
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 旧版本的备份路径:`<dest 同目录>/.<文件名>.ullold-<pid>-<seq>`。
|
/// 旧版本的备份路径:`<dest 同目录>/.<文件名>.ullold-<pid>-<seq>`。
|
||||||
///
|
///
|
||||||
/// 点号开头在 Unix 上是隐藏文件,也和 `download` 的 `.ullpart-*` 中间文件一个家族;
|
/// 点号开头在 Unix 上是隐藏文件,也和 `download` 的 `.ullpart-*` 中间文件一个家族;
|
||||||
@@ -227,48 +306,46 @@ fn failed(error: UpdateError, rolled_back: bool, backup_left: Option<PathBuf>) -
|
|||||||
/// 不会偷偷降级成复制)。
|
/// 不会偷偷降级成复制)。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn backup_path(dest: &Path) -> PathBuf {
|
pub fn backup_path(dest: &Path) -> PathBuf {
|
||||||
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
|
tagged_path(dest, OLD_TAG)
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 改名,失败时按策略重试(占用类错误才值得重试)。
|
/// 新版本的中转名:`<dest 同目录>/.<文件名>.ullnew-<pid>-<seq>`。
|
||||||
fn rename_with_retry(from: &Path, to: &Path, policy: ReplacePolicy) -> io::Result<()> {
|
///
|
||||||
let attempts = policy.attempts.max(1);
|
/// **必须与 dest 同目录**:这是「不经复制就把新字节原子地放到目标位置上」的全部前提。
|
||||||
let mut last = None;
|
fn new_path(dest: &Path) -> PathBuf {
|
||||||
for attempt in 0..attempts {
|
tagged_path(dest, NEW_TAG)
|
||||||
match fs::rename(from, to) {
|
}
|
||||||
Ok(()) => return Ok(()),
|
|
||||||
Err(error) => {
|
/// 只留最近一份旧版本备份:删掉同目录里更早的 `.<dest 文件名>.ullold-*` 与残留的 `.ullnew-*`。
|
||||||
let retryable = is_transient(&error) && attempt + 1 < attempts;
|
///
|
||||||
if !retryable {
|
/// 删失败不当错误(只是垃圾文件,下次 apply 再清);只在替换成功之后调用,所以正在用的
|
||||||
return Err(error);
|
/// 那一份(`keep`)不会被动。`.ullnew-*` 全是垃圾:本次那份在调用前已经被改名成 dest 了,
|
||||||
}
|
/// 还留着的只可能是更早某次失败遗留的中转文件(本次失败路径自己会删,删不掉才会留到这里)。
|
||||||
std::thread::sleep(policy.delay);
|
///
|
||||||
last = Some(error);
|
/// 前提是「同一个 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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -30,10 +30,17 @@
|
|||||||
//! manifest 清单模型 + JSON 校验 target 目标平台与包形态
|
//! manifest 清单模型 + JSON 校验 target 目标平台与包形态
|
||||||
//! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50)
|
//! source UpdateSource trait + 本地目录源 github GitHub Releases 源(D50)
|
||||||
//! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急
|
//! verify 暂存 + 长度/摘要校验 checker 选择哪一份发布、急不急
|
||||||
//! install 原子替换 + 回滚 error 分类与文案
|
//! install 原子替换 + 回滚 replace_io 改名/复制/重试与跨卷判据(注入点)
|
||||||
//! job 检查/应用的一条龙(GUI 与 CLI 共用同一入口)
|
//! error 分类与文案 job 检查/应用的一条龙(GUI 与 CLI 共用同一入口)
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
//! # 跨卷(M171)
|
||||||
|
//!
|
||||||
|
//! 暂存目录在数据根下、目标 exe 在安装目录,两者可能不在同一卷(便携版放 U 盘、数据根在 D 盘而
|
||||||
|
//! 程序在 C 盘)。`rename` 不能跨卷,所以 [`install_staged`] 先把新包落成**目标同目录**的
|
||||||
|
//! `.ullnew-*` 中转名(同卷改名;跨卷才复制到这儿并核对字节数),再由一次同卷改名顶到目标位置。
|
||||||
|
//! 目标路径上任何时刻都不是半截文件,同一卷路径也**不会多搬一个字节**。
|
||||||
|
//!
|
||||||
//! # 刻意不做的事
|
//! # 刻意不做的事
|
||||||
//!
|
//!
|
||||||
//! * **不碰 UI**:本模块只产出可序列化的判定结果与错误分类,弹窗/进度/文案排版在壳层。
|
//! * **不碰 UI**:本模块只产出可序列化的判定结果与错误分类,弹窗/进度/文案排版在壳层。
|
||||||
@@ -50,6 +57,7 @@ pub mod github;
|
|||||||
pub mod install;
|
pub mod install;
|
||||||
pub mod job;
|
pub mod job;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
|
mod replace_io;
|
||||||
pub mod source;
|
pub mod source;
|
||||||
pub mod target;
|
pub mod target;
|
||||||
pub mod verify;
|
pub mod verify;
|
||||||
|
|||||||
@@ -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<u64>;
|
||||||
|
/// 删文件(清理中转名)。
|
||||||
|
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<u64> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -5,14 +5,14 @@
|
|||||||
//! 撞共享冲突。它不是「运行中的 exe」(那需要真跑一个子进程,属真机验收),这一点在报告里写明。
|
//! 撞共享冲突。它不是「运行中的 exe」(那需要真跑一个子进程,属真机验收),这一点在报告里写明。
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::update::manifest::ReleaseManifest;
|
use crate::update::manifest::ReleaseManifest;
|
||||||
use crate::update::{
|
use crate::update::{
|
||||||
install_staged, stage, DirSource, InstallFailure, ReplacePolicy, StagedPackage,
|
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 UPDATE: &[u8] = b"new build bytes";
|
||||||
const OLD: &[u8] = b"old build bytes";
|
const OLD: &[u8] = b"old build bytes";
|
||||||
@@ -41,20 +41,6 @@ fn staged(tmp: &TempDir) -> StagedPackage {
|
|||||||
.expect("取包与校验应通过")
|
.expect("取包与校验应通过")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn names_in(dir: &Path) -> Vec<String> {
|
|
||||||
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::<Vec<_>>();
|
|
||||||
names.sort();
|
|
||||||
names
|
|
||||||
}
|
|
||||||
Err(_) => Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn installs_over_an_existing_file_and_keeps_the_backup() {
|
fn installs_over_an_existing_file_and_keeps_the_backup() {
|
||||||
let tmp = TempDir::new("install-ok");
|
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(),
|
||||||
|
],
|
||||||
|
"只该留本次备份,且不许碰别的程序的备份"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<usize>,
|
||||||
|
renames: Cell<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<u64> {
|
||||||
|
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("父目录"));
|
||||||
|
}
|
||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
//! * 清单校验:重复版本、零长度、缺摘要/摘要格式错、未知平台与格式;
|
//! * 清单校验:重复版本、零长度、缺摘要/摘要格式错、未知平台与格式;
|
||||||
//! * 渠道准入:阶梯比较 + 预发布阶段表;
|
//! * 渠道准入:阶梯比较 + 预发布阶段表;
|
||||||
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
|
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
|
||||||
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
|
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用、
|
||||||
|
//! 跨卷(暂存在 A 卷、目标在 B 卷)安装成功且字节完整、中途失败不留半截文件、
|
||||||
|
//! 同卷不多搬一个字节、旧备份只留最近一份;
|
||||||
//! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、
|
//! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、
|
||||||
//! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来,
|
//! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来,
|
||||||
//! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给);
|
//! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给);
|
||||||
@@ -19,6 +21,8 @@ mod channel_cases;
|
|||||||
mod checker_cases;
|
mod checker_cases;
|
||||||
mod github_cases;
|
mod github_cases;
|
||||||
mod install_cases;
|
mod install_cases;
|
||||||
|
mod install_io_cases;
|
||||||
|
mod install_xdev_cases;
|
||||||
mod job_cases;
|
mod job_cases;
|
||||||
mod manifest_cases;
|
mod manifest_cases;
|
||||||
mod source_cases;
|
mod source_cases;
|
||||||
|
|||||||
@@ -54,6 +54,118 @@ impl Drop for TempDir {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 目录里的文件名(读不到就当空;排序过,断言可写死)。
|
||||||
|
pub(crate) fn names_in(dir: &Path) -> Vec<String> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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/<uid>`、`/tmp`(Unix)。
|
||||||
|
pub(crate) struct XdevRoot {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XdevRoot {
|
||||||
|
/// 探一块异卷目录;找不到返回 `None`(调用方**跳过**并在输出里说明原因)。
|
||||||
|
pub(crate) fn probe(tag: &str) -> Option<Self> {
|
||||||
|
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<PathBuf> {
|
||||||
|
#[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。
|
/// 小写十六进制 SHA-256。
|
||||||
pub(crate) fn sha256(bytes: &[u8]) -> String {
|
pub(crate) fn sha256(bytes: &[u8]) -> String {
|
||||||
digest_bytes(HashAlgo::Sha256, bytes)
|
digest_bytes(HashAlgo::Sha256, bytes)
|
||||||
|
|||||||
Reference in new issue
Block a user