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:
AskaEth committed 2026-11-08 13:06:57 +08:00
1 parent ab79a28b28
commit 36ff193609
11 files changed
+1007 -119

No files matched your search

+166 -89
View File
@@ -1,4 +1,5 @@
//! 原子替换:先把旧文件改名挪开,再把新文件搬进来;任一步失败都能回滚。
//! 原子替换:先把新包落到**目标同目录**的中转名,再把旧文件改名挪开、中转名顶上;
//! 任一步失败都能回滚,**目标路径上永远不会出现半截文件**。
//!
//! # 为什么不用「直接覆盖」
//!
@@ -8,16 +9,38 @@
//! 把旧文件挪到同目录的备份名,让新文件顶替原来的路径。而改名本身也可能被拒绝(杀软扫描
//! 句柄、编辑器打开、另一个进程正在写),所以失败必须能回到旧版本——这正是本模块存在的理由。
//!
//! 三步写法,Unix 上也照这个顺序跑(Unix 本可直接 `rename` 覆盖,但两套语义分叉之后,
//! 回滚路径只有 Windows 被真正跑过,最容易坏的那一半就没人测了):
//! # 为什么先落中转名(M171 跨卷修复)
//!
//! 暂存包在 `<数据根>/cache/update/staging`,目标 exe 在安装目录:**两者可能不在同一卷**
//! (便携版放 U 盘、数据根在 D 盘而程序在 C 盘、部分 macOS 配置)。`rename` 不能跨卷
//! (Unix `EXDEV` / Windows `ERROR_NOT_SAME_DEVICE`,`std` 不会偷偷降级成复制),
//! 于是「暂存包 → 目标」那一步必然失败——M169 复审判定这是已发布版本上的真实 bug。
//!
//! 修法不是「把 rename 换成 copy」(那会让目标路径上出现半截文件),而是把**原子替换的前提**
//! 变成不依赖跨卷:
//!
//! ```text
//! 1. dest 存在 → rename(dest → <同目录备份>) 旧版本先安全落地
//! 2. rename(暂存包 → dest) 新版本就位
//! 0. 新包 → <目标同目录>/.<文件名>.ullnew-<pid>-<seq> 同卷改名;跨卷时复制到这儿并核对字节数
//! 1. dest 存在 → rename(dest → <同目录 .ullold-…>) 旧版本先安全落地
//! 2. rename(中转名 → dest) 新版本就位(同卷,原子)
//! 3a. 成功 → 返回 InstallReceipt(备份留着,等新版真的能起来再删)
//! 3b. 失败 → rename(备份 → dest) 回滚,并把「回滚是否成功」如实报给调用方
//! ```
//!
//! 关键性质:**第 2 步之后目标路径要么是旧字节要么是新字节**,不可能是一半。跨卷那条路多出来的
//! 复制只发生在第 0 步,落的是中转名:复制到一半失败 → 删中转名、目标一个字节都没动
//! (多数情况下备份都还没建)。同一卷上走的是改名不是复制,**没有一个字节被多搬一次**。
//!
//! Unix 上也照这个顺序跑(Unix 本可直接 `rename` 覆盖,但两套语义分叉之后,回滚路径只有 Windows
//! 被真正跑过,最容易坏的那一半就没人测了)。
//!
//! # 旧版本备份留几份
//!
//! 替换成功的那一刻会把**同目录里更早的** `.<同名文件>.ullold-*` 删掉,只留本次这一份
//! (见 `prune_stale_artifacts`):旧实现在每次 apply 都留一份、而全仓**没有任何调用方**调
//! [`InstallReceipt::remove_backup`],等于每更新一次就在安装目录里堆一个 20+ MB 的 exe。
//! 「留多份」唯一能多给的东西是「回退到更早的版本」,而回滚只用最近一份,所以按份数上界收敛成 1。
//!
//! # 本模块**不**负责的事
//!
//! * **不判断现在能不能替换**:占用/杀软/权限都在 [`ReplacePolicy`] 的重试里撞,撞不动就回滚。
@@ -27,10 +50,10 @@
//! (改字节这条路根本不存在)。这也是本模块「先改名、再搬入」而不是「直接覆盖」的原因。
//! 别照早先那版文档去设计「退出自身 → 由另一个进程搬运」的双进程方案(HMCL 的 `--apply-to`):
//! 那条路在 Windows 上不是必需的。(Unix 上 `rename` 本就可以覆盖,两步写法是为了两边同一条
//! 回滚路径,见下。)
//! 回滚路径,见上。)
//! * **不装安装器形态**(msi/dmg/deb/rpm):那些该交给系统包管理器,
//! [`crate::update::PackageFormat::needs_installer`] 是给上层的判据。
//! * **不重启、不删备份**。备份何时删由上层决定(新版启动成功后再
//! * **不重启、不删备份**(最新一份何时删由上层决定:新版启动成功后
//! [`InstallReceipt::remove_backup`])。
//!
//! # 单测覆盖到哪
@@ -40,51 +63,31 @@
//! 让 `rename` 撞共享冲突,于是在 Windows 上能看到「重试 → 仍失败 → 旧版本完好」。
//! 那条用例是 `#[cfg(windows)]`:Unix 允许改名打开中的文件,本来就该成功。
//!
//! 但它不是「运行中的 exe」——那需要真的跑一个子进程并替换它,属真机验收,不在单测范围。
//! 跨卷场景是**真的跨卷**(`install_xdev_cases` 拿一次真实 rename 探出第二块卷),真盘上没法
//! 确定性复现的「跨卷被拒」「复制/改名到一半失败」则由 `install_io_cases` 的注入器打出来。
//! 它都不是「运行中的 exe」——那需要真的跑一个子进程并替换它,属真机验收,不在单测范围。
//! 另外注意:`std::fs::File::open` **默认共享删除**,用它造的「占用」不生效,
//! 写这类用例时必须显式指定 `share_mode`。
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use super::error::UpdateError;
use super::replace_io::{is_cross_device, rename_with_retry, RealFs, ReplaceFs};
use super::verify::StagedPackage;
/// 同进程内备份文件名去重序号。
pub use super::replace_io::ReplacePolicy;
/// 同进程内备份 / 中转文件名去重序号。
static SEQ: AtomicU64 = AtomicU64::new(0);
/// 替换策略:改名失败时重试几次、每次间隔多久。
///
/// 默认 5 次 × 80ms:足够扛过杀软扫描/索引器短暂占用,又不至于让用户在「更新」按钮上
/// 干等半分钟。真正长期的占用(另一个启动器实例在跑)重试多少次都没用,直接报错更诚实。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplacePolicy {
/// 总尝试次数(含首次),`0` 按 `1` 处理。
pub attempts: u32,
/// 两次尝试之间的等待。
pub delay: Duration,
}
/// 旧版本备份的名字标签(`.<文件名>.ullold-<pid>-<seq>`)。
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-<pid>-<seq>`)。
const NEW_TAG: &str = "ullnew";
/// 替换成功后的收据。
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -121,7 +124,7 @@ impl InstallReceipt {
let Some(backup) = self.backup.as_ref() else {
return Ok(());
};
rename_with_retry(backup, &self.dest, policy)
rename_with_retry(&RealFs, backup, &self.dest, policy)
.map_err(|error| UpdateError::io_at(&self.dest, &error))?;
self.backup = None;
Ok(())
@@ -167,12 +170,24 @@ impl std::error::Error for InstallFailure {}
///
/// # Errors
///
/// 目录建不出来、目标被占用、改名失败都返回 [`InstallFailure`],
/// 目录建不出来、目标被占用、改名/跨卷复制失败都返回 [`InstallFailure`],
/// 其中的 `rolled_back` 如实反映旧版本有没有回到原位。
pub fn install_staged(
staged: &StagedPackage,
dest: &Path,
policy: ReplacePolicy,
) -> Result<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> {
if let Some(parent) = dest
.parent()
@@ -182,28 +197,44 @@ pub fn install_staged(
.map_err(|error| failed(UpdateError::io_at(parent, &error), true, None))?;
}
// 第 0 步:新包 → 目标同目录的中转名。**先做这一步**:它在目标没被碰过的情况下把
// 「搬不动」暴露出来(跨卷、临时目录写不进),失败时连备份都不用建。
let new = new_path(dest);
if let Err(error) = materialize(fs, staged, &new, policy) {
let _ = fs.remove(&new);
return Err(failed(UpdateError::io_at(dest, &error), true, None));
}
// 第 1 步:dest → 同目录备份。
let mut backup = None;
if fs::symlink_metadata(dest).is_ok() {
let path = backup_path(dest);
rename_with_retry(dest, &path, policy)
.map_err(|error| failed(UpdateError::io_at(dest, &error), true, None))?;
if let Err(error) = rename_with_retry(fs, dest, &path, policy) {
let _ = fs.remove(&new);
return Err(failed(UpdateError::io_at(dest, &error), true, None));
}
backup = Some(path);
}
match rename_with_retry(staged.path(), dest, policy) {
Ok(()) => Ok(InstallReceipt {
dest: dest.to_path_buf(),
backup,
bytes: staged.size(),
}),
// 第 2 步:中转名 → dest(同卷,原子)。
match rename_with_retry(fs, &new, dest, policy) {
Ok(()) => {
prune_stale_artifacts(backup.as_deref(), dest);
Ok(InstallReceipt {
dest: dest.to_path_buf(),
backup,
bytes: staged.size(),
})
}
Err(error) => {
let _ = fs.remove(&new);
let error = UpdateError::io_at(dest, &error);
let Some(backup_path) = backup else {
// 目标原本不存在:没有旧版本要恢复,目标也没被写坏,
// 所以按「旧版本仍在原位」上报(新安装失败 ≠ 现有安装被破坏)。
return Err(failed(error, true, None));
};
match rename_with_retry(&backup_path, dest, policy) {
match rename_with_retry(fs, &backup_path, dest, policy) {
Ok(()) => Err(failed(error, true, None)),
Err(_) => Err(failed(error, false, Some(backup_path))),
}
@@ -211,6 +242,41 @@ pub fn install_staged(
}
}
/// 第 0 步:把暂存包落成 `new`(目标同目录)。同卷改名一步到位;跨卷才复制,并核对字节数。
fn materialize(
fs: &dyn ReplaceFs,
staged: &StagedPackage,
new: &Path,
policy: ReplacePolicy,
) -> std::io::Result<()> {
match rename_with_retry(fs, staged.path(), new, policy) {
Ok(()) => Ok(()),
// 跨卷不重试:换多少次都不会变成同卷。复制是**唯一**的降级方向,而且只能往
// 目标同目录的中转名里复制——绝不许直接往 dest 上复制。
Err(error) if is_cross_device(&error) => copy_across_volumes(fs, staged, new),
Err(error) => Err(error),
}
}
/// 跨卷降级:复制到中转名,并核对写出的字节数(少了就是半截,直接判失败)。
///
/// 复制**不重试**:它不是占用问题,重来一次只会再搬几十 MB。失败后留下的半截文件由
/// `install_with` 删掉(`fs::copy` 自己不会收拾)。
fn copy_across_volumes(
fs: &dyn ReplaceFs,
staged: &StagedPackage,
new: &Path,
) -> std::io::Result<()> {
let copied = fs.copy(staged.path(), new)?;
if copied != staged.size() {
return Err(std::io::Error::other(format!(
"跨卷复制只写出 {copied} 字节,暂存包是 {} 字节(拒绝把它顶到目标上)",
staged.size()
)));
}
Ok(())
}
fn failed(error: UpdateError, rolled_back: bool, backup_left: Option<PathBuf>) -> InstallFailure {
InstallFailure {
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>`。
///
/// 点号开头在 Unix 上是隐藏文件,也和 `download` 的 `.ullpart-*` 中间文件一个家族;
@@ -227,48 +306,46 @@ fn failed(error: UpdateError, rolled_back: bool, backup_left: Option<PathBuf>) -
/// 不会偷偷降级成复制)。
#[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);
}
/// 新版本的中转名:`<dest 同目录>/.<文件名>.ullnew-<pid>-<seq>`。
///
/// **必须与 dest 同目录**:这是「不经复制就把新字节原子地放到目标位置上」的全部前提。
fn new_path(dest: &Path) -> PathBuf {
tagged_path(dest, NEW_TAG)
}
/// 只留最近一份旧版本备份:删掉同目录里更早的 `.<dest 文件名>.ullold-*` 与残留的 `.ullnew-*`。
///
/// 删失败不当错误(只是垃圾文件,下次 apply 再清);只在替换成功之后调用,所以正在用的
/// 那一份(`keep`)不会被动。`.ullnew-*` 全是垃圾:本次那份在调用前已经被改名成 dest 了,
/// 还留着的只可能是更早某次失败遗留的中转文件(本次失败路径自己会删,删不掉才会留到这里)。
///
/// 前提是「同一个 exe 只有一个更新者在动」。两个进程同时替换同一路径本来就不成立:
/// 后一个回滚会把前一个刚装上的新版本换成旧字节。这条不因为保留多份而变好,反倒因为
/// 每份都是几十 MB 的 exe 而变坏。
fn prune_stale_artifacts(keep: Option<&Path>, dest: &Path) {
let (Some(dir), Some(name)) = (dest.parent(), dest.file_name()) else {
return;
};
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let name = name.to_string_lossy();
let old_prefix = format!(".{name}.{OLD_TAG}-");
let new_prefix = format!(".{name}.{NEW_TAG}-");
for entry in entries.flatten() {
let path = entry.path();
if Some(path.as_path()) == keep {
continue;
}
let stale = path.file_name().is_some_and(|other| {
let other = other.to_string_lossy();
other.starts_with(&old_prefix) || other.starts_with(&new_prefix)
});
if stale {
let _ = fs::remove_file(&path);
}
}
Err(last.unwrap_or_else(|| io::Error::other("rename 未执行")))
}
/// 是否属于「等一会儿可能就好了」的失败。
///
/// Windows 的共享冲突(`os error 32`)落在 `ErrorKind::Other` 里(`std` 没有专门的 kind),
/// 所以这里必须把 `Other` 也算上;`PermissionDenied` 覆盖 `ACCESS_DENIED`(杀软持有的句柄)。
fn is_transient(error: &io::Error) -> bool {
matches!(
error.kind(),
io::ErrorKind::PermissionDenied
| io::ErrorKind::Other
| io::ErrorKind::Interrupted
| io::ErrorKind::WouldBlock
)
}
+10 -2
View File
@@ -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;
+129
View File
@@ -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
}
+43 -16
View File
@@ -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<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]
fn installs_over_an_existing_file_and_keeps_the_backup() {
let tmp = TempDir::new("install-ok");
@@ -190,3 +176,44 @@ fn backup_path_stays_in_the_same_directory() {
"同一进程两次调用不能撞名"
);
}
/// 备份只留最近一份:连装两次不会在安装目录里堆两个几十 MB 的旧 exe。
///
/// 旧行为是「每次 apply 留一个、永不删」(全仓没有任何调用方调 `remove_backup`),这条用例把
/// 上界钉在「一次安装一份」上。它抓得住「prune 被删掉/被改坏」;抓不住**并发**:两个进程同时
/// 替换同一路径本来就不成立(后者的回滚会把前者的新版本换回旧字节),这一点写在 `install` 的
/// 文档里,不是这里能验的。
#[test]
fn only_the_latest_backup_is_kept() {
let tmp = TempDir::new("install-retention");
let dest = tmp.write("app/ull.exe", OLD);
// 上一次也没人删的备份(名字形态与 `backup_path` 一致,只是 pid/seq 不同)。
tmp.write("app/.ull.exe.ullold-1-1", b"older old bytes");
tmp.write("app/.ull.exe.ullold-1-2", b"even older bytes");
// 别的程序的备份:不是我们的名字,一个都不许动。
tmp.write("app/.other.exe.ullold-1-1", b"someone else's backup");
// 上一次失败遗留的中转文件:垃圾,要在这次成功之后清掉。
tmp.write(
"app/.ull.exe.ullnew-1-1",
b"half a build from a dead attempt",
);
let staged = staged(&tmp);
let receipt = install_staged(&staged, &dest, policy()).expect("替换应成功");
let backup = receipt.backup.clone().expect("本次备份");
assert_eq!(fs::read(&backup).expect("本次备份"), OLD);
assert_eq!(
names_in(dest.parent().expect("父目录")),
[
".other.exe.ullold-1-1".to_owned(),
backup
.file_name()
.expect("备份名")
.to_string_lossy()
.into_owned(),
"ull.exe".to_owned(),
],
"只该留本次备份,且不许碰别的程序的备份"
);
}
@@ -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:?}"
);
}
+5 -1
View File
@@ -6,7 +6,9 @@
//! * 清单校验:重复版本、零长度、缺摘要/摘要格式错、未知平台与格式;
//! * 渠道准入:阶梯比较 + 预发布阶段表;
//! * 校验失败:摘要不符、包体截断(短了/多了)、失败后暂存文件不残留;
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用;
//! * 替换与回滚:成功后备份仍在、搬一半失败回滚、目标目录不可写、Windows 占用、
//! 跨卷(暂存在 A 卷、目标在 B 卷)安装成功且字节完整、中途失败不留半截文件、
//! 同卷不多搬一个字节、旧备份只留最近一份;
//! * 一条龙(`job`):只有便携单文件 exe 能原地自替换(msi/zip 在取包之前就被拒)、
//! 「没有可装更新」不碰目标、测试通道是**编译期 opt-in**(官方发布构建里那段根本编不进来,
//! 带 feature 时还要 `ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1` 两档全给);
@@ -19,6 +21,8 @@ mod channel_cases;
mod checker_cases;
mod github_cases;
mod install_cases;
mod install_io_cases;
mod install_xdev_cases;
mod job_cases;
mod manifest_cases;
mod source_cases;
+112
View File
@@ -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。
pub(crate) fn sha256(bytes: &[u8]) -> String {
digest_bytes(HashAlgo::Sha256, bytes)