feat(core,presenter,app-iced): 存档三个写操作(重命名 / 删除 / 备份)+ 行操作区(M247 SAVESOPS)

core::game::saves 新增 write 模块,三个入口的第一句都是 boundary::guard:

- rename_world:saves/<old> → saves/<new>,撞名/同名/非法名字各自具名拒绝
- delete_world:递归删(不跟随符号链接、层级上限 64),先过边界校验
- backup_world:流式 zip(限额预检 → 临时文件 → 原子改名),
  <世界名>-YYYYMMDD-HHMMSS.zip,撞名顺延,绝不覆盖上一份

guard 三道判据(每次写前重做,不信任调用方):名字必须是单段裸名字;
saves/ 必须是运行目录的真实直属子目录;世界目录必须是 saves/ 下的真目录
(解析后比父级,不比字符串前缀)。落点目录要求绝对路径(相对路径会落到进程 CWD,
实测过一次)。

presenter:SavesCommand 增 RenameWorld/DeleteWorld/BackupWorld,host 三个后台入口
写成功后重新列一遍目录并把「回执」挂在快照上(SavesOped:改名两端 / 被删的名字 /
备份落点+字节数+跳过的链接数)。SavesFailureView 增 file 与 op,写失败八类一一对应。
SavesState 增 draft / pending / confirm 三样交互态(草稿是行内输入缓冲,
pending 与 ModsState::pending 同一条先例,confirm 是删除确认框在等谁)。

app-iced:世界行右侧重命名/备份/删除;行内输入框改名(现成模态队列没有「输入变了」
这一条消息,用输入模态等于重写一套);删除走现成 modal 的 Confirm,主按钮落点接在
app/update/dialog.rs。回执行画在卡片里(备份落点看不见就是静默失败)。

i18n:新增 25 条中英成对 key + 金表同步;删掉 M235 的「这一版只读」那条(写操作接上
之后它是假的)。出图夹具 ULL_SAVES_FIXTURE=rename|confirm|backed-up 三种形态。
This commit is contained in:
AskaEth committed 2026-11-11 13:29:56 +08:00
1 parent 3e852331f4
commit c921b9c185
28 files changed
+3016 -216

No files matched your search

+25 -4
View File
@@ -1,17 +1,31 @@
//! 存档 / 截图的**只读**列举与「打开文件夹」(M235 · STOREMVP)。
//! 存档 / 截图的列举与「打开文件夹」(M235 · STOREMVP)+ 三个写操作(M247 · SAVESOPS)。
//!
//! # 这一版做到哪、不做什么(塔裁决 U3)
//! # 读的那一半(M235)
//!
//! 用户原话是「存档截图 / 导入」,M230 审计确认三层全缺(core 没有读路径,`saves/` 只在
//! `isolation.rs` 的切换探测里被计数;presenter 没有 DTO;i18n 没有键;没有页面)。
//! U3 把本 mission 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条:
//! U3 把 M235 的边界钉成:**只读列表 + 打开文件夹,不做缩略图墙**。理由有两条:
//!
//! 1. 缩略图要引图片解码依赖,而 `AGENTS.md` §2 只允许 `palette-extract` 之外不引 image 类依赖;
//! 2. 截图/导入的产物形态还没定型,先不进产品。
//!
//! 所以这里**只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、
//! 所以 [`list`] **只读**:只调 `read_dir` / `metadata` / `symlink_metadata`,不建目录、不写文件、
//! 不改名字、不读文件内容(`icon.png` 只判存在——那是将来缩略图墙的输入,不是这一版的解码对象)。
//!
//! # 写的那一半(M247)
//!
//! 用户接着拍板:「存档按照主流的方式来」——主流第三方启动器(HMCL / PCL2)的存档区都有
//! **重命名世界 / 删除世界(二次确认)/ 备份导出**。这三个落在 [`write`]:
//!
//! | 操作 | 入口 | 边界 |
//! |---|---|---|
//! | 重命名 | [`write::rename_world`] | 只动 `saves/<old>` → `saves/<new>`;撞名拒 |
//! | 删除 | [`write::delete_world`] | 先过边界校验,再递归删;不跟随符号链接 |
//! | 备份 | [`write::backup_world`] | 流式 zip 到调用方给的目录,时戳命名,不覆盖 |
//!
//! **写操作一律自己重新校验 `run_dir` 与目标路径**(不靠调用方传对)——判据在
//! [`write`] 的模块文档里一条条列着。
//!
//! # 单根语义(D20)
//!
//! 入口是**已经派生好的运行目录**([`crate::game::isolation::derive_game_dir`] 的结果),
@@ -23,6 +37,7 @@
//!
//! 新实例两个目录都不存在,这**不是**异常(`exists = false` + 空列表)。只有「存在但不可用」
//! (符号链接 / 不是目录 / 读不了)才是 [`SavesError`];**并且照样不建目录**。
//! 写操作那边不同:目标不存在就是 [`write::SavesWriteKind::NotFound`](写不下去,得说清楚)。
//!
//! # 收纳口径
//!
@@ -40,18 +55,24 @@
//! |---|---|
//! | [`error`] | 「目录存在但不可用」的失败形状(带 OS 原文) |
//! | [`list`] | 列 `<run_dir>/saves` 与 `<run_dir>/screenshots`(纯只读) |
//! | [`write`] | 重命名 / 删除 / 备份导出(三个写操作 + 边界校验) |
//!
//! 「打开文件夹」不在本模块:平台差异收敛在 [`crate::platform::opener::open_dir`]
//! (`AGENTS.md` §6),本模块一个 `cfg` 都不写。
mod error;
mod list;
mod write;
pub use error::SavesError;
pub use list::{
saves_dir, scan, screenshots_dir, Area, SavesScan, SavesSkip, ScreenshotEntry, SkipKind,
WorldEntry, IMAGE_EXTENSIONS, SAVES_DIR, SCREENSHOTS_DIR, WORLD_ICON,
};
pub use write::{
backup_world, delete_world, rename_world, Backup, SavesWriteError, SavesWriteKind, BACKUP_EXT,
MAX_BACKUP_ENTRIES, MAX_BACKUP_TOTAL_BYTES,
};
#[cfg(test)]
mod tests;
+159
View File
@@ -0,0 +1,159 @@
//! 存档的**三个写操作**(M247 · SAVESOPS):重命名世界、删除世界、备份导出(zip)。
//!
//! # 这一版做到哪
//!
//! 用户原话是「存档按照主流的方式来」——HMCL / PCL2 的存档区都有**重命名 / 删除(二次确认)/
//! 备份导出**。M235 只做了只读列表,本模块补写的那一半。
//!
//! | 操作 | 入口 | 落盘形态 |
//! |---|---|---|
//! | 重命名世界 | [`rename_world`] | `saves/<old>` → `saves/<new>`(一次 `rename`,不搬字节) |
//! | 删除世界 | [`delete_world`] | 递归删 `saves/<name>`(**不跟随符号链接**) |
//! | 备份导出 | [`backup_world`] | `<dest>/<世界名>-YYYYMMDD-HHMMSS.zip`(流式 zip) |
//!
//! # 边界校验是**每次自己重做**的,不靠调用方传对
//!
//! 三个入口的第一件事都是 [`boundary::guard`]:它重新解析 `<run_dir>`、重新确认
//! `<run_dir>/saves` 是**直属子目录且不是链接**、重新确认目标那条世界目录是**真的目录而不是链接**。
//! 调用方(presenter host)拿到的是「实例 id + 世界名」这种可被 UI 传坏的东西,所以
//! **r1 复审那种「信任上游」在这里一律不成立**:
//!
//! ```text
//! 名字 → 必须是单段裸名字(`crate::addon::pack::path::normalize_file` 后原样相等)
//! saves/ → 必须是 <run_dir> 的真实直属子目录(解析后比父级,不拿字符串前缀当判据)
//! 世界 → 必须是 saves/ 下的真目录(symlink_metadata:符号链接、非目录一律拒)
//! ```
//!
//! 三条各自对应 [`SavesWriteKind`] 里的一个具名类别,测试逐条钉住(见 `write/tests.rs`)。
//!
//! # 为什么写操作不走 `saves::scan` 的结果
//!
//! 扫描结果是**上一拍**的事实。用户在文件管理器里换了名字、别的程序删了一个目录,扫描里都还是
//! 旧值。所以写路径上不「先查快照再下手」,而是把边界与目标重新查一遍——快照只用来画界面。
//!
//! # 不跟随符号链接(`AGENTS.md` 的写盘纪律)
//!
//! | 位置 | 判据 | 为什么 |
//! |---|---|---|
//! | `saves/` 本身是链接 | 拒绝([`SavesWriteKind::SymbolicLink`]) | 跟下去就写到实例目录之外了 |
//! | 世界目录是链接 | 拒绝 | 同上;删除更危险(会把链接指向的整棵树删掉) |
//! | 世界**内部**的链接 | 删除:只删链接本身;备份:跳过并计数 | 两种都不跟随,但都不静默丢(`skipped` 会计数) |
//!
//! # 失败形状
//!
//! [`SavesWriteError`] = **具名类别 + 出事的那个路径 + OS 原文**。不拼人读句子(M161 纪律:
//! 文案由 app 层按 i18n key 现求)。UI 上每一种都有话可说——见 [`SavesWriteKind`] 每一个变体的文档。
//!
//! # 模块
//!
//! | 文件 | 职责 |
//! |---|---|
//! | [`boundary`] | 名字规则 + `<run_dir>/saves/<name>` 的三道校验 + 重命名 |
//! | [`remove`] | 递归删除(不跟随链接,层级有上限) |
//! | [`backup`] | 流式 zip(限额预检 → 写临时文件 → 原子改名) |
use std::fmt;
use std::path::PathBuf;
mod backup;
mod boundary;
mod remove;
#[cfg(test)]
mod tests;
pub use backup::{backup_world, Backup, BACKUP_EXT, MAX_BACKUP_ENTRIES, MAX_BACKUP_TOTAL_BYTES};
pub use boundary::rename_world;
pub use remove::delete_world;
/// 写操作失败的类别(**结构**;文案由 app 层按 i18n key 现求)。
///
/// 每个变体在 UI 上都有一句对应的话,没有一种是「静默失败」:
///
/// | 类别 | 用户看到什么 |
/// |---|---|
/// | [`Boundary`](Self::Boundary) | 这个路径落在 `saves/` 之外(或层级深得不像一个世界) |
/// | [`UnsafeName`](Self::UnsafeName) | 名字里有不能用的字符(`/` `\` `:` `..` …) |
/// | [`SymbolicLink`](Self::SymbolicLink) | 这个位置是符号链接,不跟随 |
/// | [`NotFound`](Self::NotFound) | 那个世界(或 `saves/` 目录)已经不在了 |
/// | [`NotADirectory`](Self::NotADirectory) | 那个名字底下不是目录 |
/// | [`Conflict`](Self::Conflict) | 同名世界已存在 / 备份文件名撞满 |
/// | [`TooLarge`](Self::TooLarge) | 备份超过体积或条目上限(附上限数值) |
/// | [`Io`](Self::Io) | 其它系统错误(权限 / 被占用),OS 原文进括号 |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SavesWriteKind {
/// 目标越出 `<run_dir>/saves`(名字可穿越、或解析后落在外面、或层级异常深)。
Boundary,
/// 名字过不了路径规则(空串、`.`、`..`、含 `/` `\` `:` NUL,或规整后与原串不等)。
UnsafeName,
/// 目标(`saves/` 那一层或世界那一条)是符号链接:不跟随。
SymbolicLink,
/// 目标不在了(`run_dir` / `saves/` / 世界目录三者之一)。
NotFound,
/// 目标存在但不是目录。
NotADirectory,
/// 目标已存在(重命名撞名、备份文件名撞满)。
Conflict,
/// 备份超过体积 / 条目上限。
TooLarge,
/// 其它 IO 失败(权限、被别的进程占用……OS 原文进 `detail`)。
Io,
}
/// 一次写操作失败。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SavesWriteError {
/// 出事的那个路径(能确定时给最内层的那一条)。
pub path: PathBuf,
/// 失败类别。
pub kind: SavesWriteKind,
/// 人读原因(OS 原文或判据本身),**只进括号**,不进主句。
pub detail: String,
}
impl SavesWriteError {
pub(super) fn new(
path: &std::path::Path,
kind: SavesWriteKind,
detail: impl Into<String>,
) -> Self {
Self {
path: path.to_path_buf(),
kind,
detail: detail.into(),
}
}
/// 一个 IO 错误:类别 [`SavesWriteKind::Io`],`detail` 是 OS 原文。
pub(super) fn io(path: &std::path::Path, error: &std::io::Error) -> Self {
Self::new(path, SavesWriteKind::Io, error.to_string())
}
}
impl fmt::Display for SavesWriteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}({}):{}",
self.path.display(),
kind_label(self.kind),
self.detail
)
}
}
impl std::error::Error for SavesWriteError {}
/// 类别的中文短名(**日志与测试诊断用**,不是界面文案:界面按 i18n key 现求)。
fn kind_label(kind: SavesWriteKind) -> &'static str {
match kind {
SavesWriteKind::Boundary => "路径越界",
SavesWriteKind::UnsafeName => "名字不可用",
SavesWriteKind::SymbolicLink => "是符号链接",
SavesWriteKind::NotFound => "已不在",
SavesWriteKind::NotADirectory => "不是目录",
SavesWriteKind::Conflict => "同名冲突",
SavesWriteKind::TooLarge => "超出上限",
SavesWriteKind::Io => "系统错误",
}
}
+365
View File
@@ -0,0 +1,365 @@
//! 世界目录 → zip 备份(流式、限额预检、原子落点)。
//!
//! # 落点与命名
//!
//! `<dest_dir>/<世界名>-YYYYMMDD-HHMMSS.zip`(UTC 时间戳)。落点名撞上已有文件就顺延
//! `-2` … `-99`,撞满报 [`SavesWriteKind::Conflict`]——**绝不覆盖**上一次备份。
//!
//! `dest_dir` 由调用方给(presenter host 的默认值是启动器自己的 `exports/saves/`,见
//! `core::launch::crash_bundle::default_target_dir` 的同一先例)。本模块不猜「用户想要哪个目录」,
//! 也不在 `saves/` 里偷偷建备份目录:`saves/` 下多一个 `backups/` 会在列表里长成一个世界。
//!
//! # 为什么先预检再写第一个字节
//!
//! 一条写死的边界(与 `addon::pack::format::write` 的 `preflight` 同一条纪律):**超限要在
//! 内容进内存之前就拒**。这里的预检只走 `metadata().len()`:顶层递归一遍算条目数与未压缩总量,
//! 超限时连临时文件都不建。之后写循环里还有第二道(**已写出**的字节数),因为预检与写入之间
//! 文件可能变大(实例目录不是快照)。
//!
//! 上限本身是 [`MAX_BACKUP_TOTAL_BYTES`](3 GiB)与 [`MAX_BACKUP_ENTRIES`](20 万条):
//! 前者压在 zip 的 32 位尺寸阈值(4 GiB)之下,所以**永远不会写 ZIP64**——一个能被自己与
//! 常见第三方工具读回来的包,比一个「理论上更大」的包有用。
//!
//! # 为什么写临时文件再改名
//!
//! 半截的备份文件比没有备份更坏:用户会以为它是一份完整的备份。所以写到 `<名字>.part`,
//! 收尾成功才 `rename` 到最终名字;任何一步失败都把临时文件删掉(同 `addon::pack` 的
//! `temporary_path` 口径)。
//!
//! # 符号链接
//!
//! **不跟随**:世界内部遇到链接只**跳过并计数**([`Backup::skipped`]),不打包链接指向的内容。
//! 跳过不是静默的——`skipped` 会进 UI 的那句话(「跳过了 2 个符号链接」),与 M235「不静默丢」同一条纪律。
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use super::boundary::guard;
use super::{SavesWriteError, SavesWriteKind};
/// 备份文件的扩展名。
pub const BACKUP_EXT: &str = "zip";
/// 未压缩总量上限(3 GiB:压在 zip 的 32 位尺寸阈值之下,见模块文档)。
pub const MAX_BACKUP_TOTAL_BYTES: u64 = 3 * 1024 * 1024 * 1024;
/// 条目数上限(20 万条;一个巨型世界的 `region/` 通常是几千条)。
pub const MAX_BACKUP_ENTRIES: usize = 200_000;
/// 搬内容时的缓冲(流式,不把文件整份读进内存)。
const COPY_CHUNK: usize = 1024 * 1024;
/// 撞名时最多顺延多少个后缀(`-2` … `-99`)。
const MAX_SUFFIX: u32 = 99;
/// 一次成功的备份。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Backup {
/// 落点(最终名字,不是临时文件)。
pub path: PathBuf,
/// 写出来的 zip 字节数。
pub bytes: u64,
/// 源目录的**未压缩**字节数(预检算出来的)。
pub sourced: u64,
/// 进了包的条目数(含目录条目)。
pub entries: usize,
/// 被跳过的符号链接数(不跟随;这个数要能报到界面上)。
pub skipped: usize,
}
/// 写操作的限额(生产口径见 [`Limits::production`];测试可以用极小的值钉住边界)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Limits {
/// 未压缩总量上限。
pub(super) total: u64,
/// 条目数上限。
pub(super) entries: usize,
}
impl Limits {
pub(super) const fn production() -> Self {
Self {
total: MAX_BACKUP_TOTAL_BYTES,
entries: MAX_BACKUP_ENTRIES,
}
}
#[cfg(test)]
pub(super) const fn new(total: u64, entries: usize) -> Self {
Self { total, entries }
}
}
/// 备份一个世界目录到 `dest_dir`,返回落点与统计。
///
/// 第一句永远是 [`guard`]:源必须是 `<run_dir>/saves` 下的真目录。
///
/// # Errors
///
/// | 情况 | 类别 |
/// |---|---|
/// | 源越界 / 是链接 / 不在 / 不是目录 | `Boundary` / `SymbolicLink` / `NotFound` / `NotADirectory` |
/// | 超过 [`MAX_BACKUP_TOTAL_BYTES`] 或 [`MAX_BACKUP_ENTRIES`] | [`SavesWriteKind::TooLarge`] |
/// | 撞名顺延到 `-99` 还被占 | [`SavesWriteKind::Conflict`] |
/// | 建目录 / 读文件 / 写 zip / 改名失败 | [`SavesWriteKind::Io`](`path` 指出卡在哪一条) |
pub fn backup_world(
run_dir: &Path,
name: &str,
dest_dir: &Path,
) -> Result<Backup, SavesWriteError> {
backup_world_at(
run_dir,
name,
dest_dir,
SystemTime::now(),
Limits::production(),
)
}
/// 带显式时钟与限额的那一份(测试用它钉「时戳命名」与「限额拦在内容进内存之前」)。
pub(super) fn backup_world_at(
run_dir: &Path,
name: &str,
dest_dir: &Path,
at: SystemTime,
limits: Limits,
) -> Result<Backup, SavesWriteError> {
let target = guard(run_dir, name)?;
// 落点目录必须是**绝对且非空**的:相对路径会落到进程当前目录(测试里真的发生过——
// 一次 `dest = ""` 把 zip 写进了 crate 根),这属于「写到用户没想到的地方」,直接拒。
if dest_dir.as_os_str().is_empty() || !dest_dir.is_absolute() {
return Err(SavesWriteError::new(
dest_dir,
SavesWriteKind::Boundary,
format!(
"落点目录需要绝对路径,收到 `{}`(相对路径会落到进程当前目录)",
dest_dir.display()
),
));
}
// 预检:只走 metadata,不读内容;超限时下面的临时文件连建都不建。
let plan = plan(&target.world, limits)?;
std::fs::create_dir_all(dest_dir).map_err(|error| SavesWriteError::io(dest_dir, &error))?;
let path = unique_target(dest_dir, name, at)?;
let temp = temporary_path(&path);
// 上一轮被打断留下的临时文件:先清掉(同名字段是 `.part`,扫不到就继续)。
let _ = std::fs::remove_file(&temp);
match write_zip(&plan, &temp, limits) {
Ok(()) => {}
Err(error) => {
let _ = std::fs::remove_file(&temp);
return Err(error);
}
}
if let Err(error) = std::fs::rename(&temp, &path) {
let _ = std::fs::remove_file(&temp);
return Err(SavesWriteError::io(&path, &error));
}
let bytes = std::fs::metadata(&path).map(|meta| meta.len()).unwrap_or(0);
Ok(Backup {
path,
bytes,
sourced: plan.total,
entries: plan.dirs.len() + plan.files.len(),
skipped: plan.skipped,
})
}
/// 预检结果:要打进包的那一份清单(`saves/` 之外一个字节都没碰)。
struct Plan {
/// 目录条目(zip 内名字,带结尾 `/`)。
dirs: Vec<String>,
/// 文件条目(zip 内名字 + 磁盘路径 + 字节数)。
files: Vec<(String, PathBuf, u64)>,
/// 未压缩总量。
total: u64,
/// 跳过的符号链接数。
skipped: usize,
}
/// 走一遍世界目录,算条目数与总量(**只读**,不读内容)。
fn plan(world: &Path, limits: Limits) -> Result<Plan, SavesWriteError> {
let mut out = Plan {
dirs: Vec::new(),
files: Vec::new(),
total: 0,
skipped: 0,
};
// (磁盘目录, zip 内前缀):显式栈,避免深目录上的递归栈。
let mut stack: Vec<(PathBuf, String)> = vec![(world.to_path_buf(), String::new())];
while let Some((dir, prefix)) = stack.pop() {
let read = std::fs::read_dir(&dir).map_err(|error| SavesWriteError::io(&dir, &error))?;
// 同一个目录里的顺序不影响包内容,但排序能让同名世界两次备份的条目序一致(可比对)。
let mut children: Vec<_> = read
.collect::<Result<Vec<_>, _>>()
.map_err(|error| SavesWriteError::io(&dir, &error))?;
children.sort_by_key(std::fs::DirEntry::file_name);
for child in children {
let raw = child.file_name().to_string_lossy().to_string();
let path = child.path();
let name = if prefix.is_empty() {
raw.clone()
} else {
format!("{prefix}/{raw}")
};
let meta = match std::fs::symlink_metadata(&path) {
Ok(meta) => meta,
// 预检与写入之间被删掉:当成"不在这一包里",不让整份备份失败。
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(SavesWriteError::io(&path, &error)),
};
if meta.file_type().is_symlink() {
out.skipped += 1;
continue;
}
if meta.is_dir() {
out.dirs.push(format!("{name}/"));
guard_entries(&path, out.dirs.len() + out.files.len(), limits)?;
stack.push((path, name));
continue;
}
let length = meta.len();
out.total = out.total.saturating_add(length);
out.files.push((name, path, length));
guard_total(world, out.total, limits)?;
guard_entries(world, out.dirs.len() + out.files.len(), limits)?;
}
}
Ok(out)
}
fn guard_total(path: &Path, total: u64, limits: Limits) -> Result<(), SavesWriteError> {
if total >= limits.total {
return Err(SavesWriteError::new(
path,
SavesWriteKind::TooLarge,
format!(
"世界未压缩总量超过上限 {} 字节(写出的包不写 ZIP64,所以上限压在 4 GiB 之下)",
limits.total
),
));
}
Ok(())
}
fn guard_entries(path: &Path, count: usize, limits: Limits) -> Result<(), SavesWriteError> {
if count > limits.entries {
return Err(SavesWriteError::new(
path,
SavesWriteKind::TooLarge,
format!("条目数 {count} 超过上限 {}", limits.entries),
));
}
Ok(())
}
/// 真正的 zip 写入(`temp` 由调用方负责在失败时删掉)。
fn write_zip(plan: &Plan, temp: &Path, limits: Limits) -> Result<(), SavesWriteError> {
let file = File::create(temp).map_err(|error| SavesWriteError::io(temp, &error))?;
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o644);
for dir in &plan.dirs {
writer
.add_directory(dir.as_str(), options)
.map_err(|error| SavesWriteError::new(temp, SavesWriteKind::Io, error.to_string()))?;
}
// 第二道闸:**已写出**的未压缩字节数从 0 起算,与预检结果无关(不重复累计)。
let mut written = Written::default();
let mut buffer = vec![0_u8; COPY_CHUNK];
for (name, source, _) in &plan.files {
let mut handle = File::open(source).map_err(|error| SavesWriteError::io(source, &error))?;
writer
.start_file(name.as_str(), options)
.map_err(|error| SavesWriteError::new(source, SavesWriteKind::Io, error.to_string()))?;
loop {
let read = handle
.read(&mut buffer)
.map_err(|error| SavesWriteError::io(source, &error))?;
if read == 0 {
break;
}
writer.write_all(&buffer[..read]).map_err(|error| {
SavesWriteError::new(temp, SavesWriteKind::Io, error.to_string())
})?;
written.spend(temp, read as u64, limits)?;
}
}
writer
.finish()
.map_err(|error| SavesWriteError::new(temp, SavesWriteKind::Io, error.to_string()))?;
Ok(())
}
/// 写循环的第二道闸(**已写出**的未压缩字节数)。
///
/// 起点是 0,**与预检的结果无关**:拿预检的返回值当基数会把全量声明尺寸再累加一遍
/// (`addon::pack::format::write::Written` 上记着同一条 P2-1 教训)。
#[derive(Default)]
struct Written {
total: u64,
}
impl Written {
/// 记一笔实际写出的字节,每一块之后过闸(大文件能在中途被拦下)。
fn spend(&mut self, path: &Path, bytes: u64, limits: Limits) -> Result<(), SavesWriteError> {
self.total = self.total.saturating_add(bytes);
guard_total(path, self.total, limits)
}
}
/// 落点名:`<世界名>-<时戳>.zip`,撞名顺延(见模块文档)。
fn unique_target(dest_dir: &Path, world: &str, at: SystemTime) -> Result<PathBuf, SavesWriteError> {
let stem = format!("{world}-{}", stamp(at));
for suffix in 1..=MAX_SUFFIX {
let name = if suffix == 1 {
format!("{stem}.{BACKUP_EXT}")
} else {
format!("{stem}-{suffix}.{BACKUP_EXT}")
};
let candidate = dest_dir.join(name);
match std::fs::symlink_metadata(&candidate) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(candidate),
Ok(_) => {}
Err(error) => return Err(SavesWriteError::io(&candidate, &error)),
}
}
Err(SavesWriteError::new(
dest_dir,
SavesWriteKind::Conflict,
format!("`{stem}` 的同名备份已经有 {MAX_SUFFIX} 份,换个落点目录再试"),
))
}
/// 与目标同目录的临时文件名(同卷 `rename` 才能原子替换)。
fn temporary_path(target: &Path) -> PathBuf {
let mut name = target.file_name().map_or_else(
|| std::ffi::OsString::from("backup"),
std::ffi::OsStr::to_os_string,
);
name.push(".part");
target.with_file_name(name)
}
/// `SystemTime` → `YYYYMMDD-HHMMSS`(**UTC**,与 `core::launch::time` 同一个时间口径)。
///
/// 从 RFC3339 的固定位置切片,不解析、不重排(与页面 `stamp_utc` 同一条做法)。
#[must_use]
pub fn stamp(at: SystemTime) -> String {
let text = crate::launch::time::format_rfc3339_millis(at);
let part = |range: std::ops::Range<usize>| text.get(range).unwrap_or_default();
format!(
"{}{}{}-{}{}{}",
part(0..4),
part(5..7),
part(8..10),
part(11..13),
part(14..16),
part(17..19)
)
}
@@ -0,0 +1,196 @@
//! 写操作的**边界校验**与重命名。
//!
//! # 三道校验,每次重做
//!
//! [`guard`] 是三个写操作共同的第一句:
//!
//! | 第几道 | 查什么 | 失败类别 |
//! |---|---|---|
//! | 1 | 名字是单段裸名字(`normalize_file` 之后与原串**原样相等**) | [`SavesWriteKind::UnsafeName`] |
//! | 2 | `<run_dir>/saves` 是**运行目录的直属子目录**、不是链接、是目录 | `SymbolicLink` / `NotADirectory` / `Boundary` |
//! | 3 | `saves/<name>` 是真目录(`symlink_metadata`:链接与非目录一律拒) | `SymbolicLink` / `NotADirectory` / `Boundary` |
//!
//! ## 为什么第 2 道比的是「父级」而不是字符串前缀
//!
//! `Path::starts_with` 是**逐段**比较,看起来够用;但它对「`saves/` 被换成一个指向实例外的
//! 目录联接」这种情况答不了——解析之后父级已经不是运行目录了。所以两道都比**解析后的父级**:
//! `saves` 的父级必须**就是**运行目录本身,世界目录的父级必须**就是** `saves`。这样
//! 「把 `saves` 换成链接」「把世界目录换成链接」两类越界都在同一条判据上被挡住,
//! 不依赖字符串长什么样。
//!
//! ## 为什么第 1 道要「原样相等」而不是「规整后能用」
//!
//! `normalize_file` 会**丢弃** `.` 段与空段(`a//b` → `a/b`)。对 zip 条目名那是好意,对
//! 磁盘上的目录名却是**改名**:用户输入 `a//b`,我们悄悄去动 `a/b`。所以这里要求规整结果与输入
//! 一字不差——超出的部分(`..`、`/`、`\`、`:`、NUL、绝对路径)由 `normalize_file` 一律拒掉。
//!
//! # 重命名的失败顺序
//!
//! 先校验**源**(必须是一个真世界),再校验**目标名字**,最后才碰磁盘。目标位置上
//! **存在任何东西**(目录、文件、坏链接——`symlink_metadata` 都看得见)都算冲突:
//! 「覆盖别人」这件事不在本 mission 的语义里。
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use super::{SavesWriteError, SavesWriteKind};
use crate::game::saves::{saves_dir, SAVES_DIR};
/// 一次写操作**已校验**的目标。
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Target {
/// `<run_dir>/saves`(**解析后的真实路径**:后续拼名字都拿它当父级)。
pub(super) saves: PathBuf,
/// `<run_dir>/saves/<name>`(**解析后的真实目录**)。
///
/// 它就是这一拍要动的那棵树。解析过 ⇒ 后面 `rename` / 递归删 / 打包都不必再猜路径形态。
pub(super) world: PathBuf,
}
/// 名字 → 单段裸名字(不过规则就给具名错误)。
///
/// `anchor` 只用于错误里的路径:给「用户以为自己在动的那一条」。
pub(super) fn check_name(anchor: &Path, name: &str) -> Result<(), SavesWriteError> {
if name.is_empty() {
return Err(SavesWriteError::new(
anchor,
SavesWriteKind::UnsafeName,
"名字为空",
));
}
// 判据复用整合包侧那一份(不在存档这边写第二套规则),再补一条「规整后必须原样相等且只有一段」。
// `normalize_file("a/b")` 会把 `a/b` 原样返回(那是包内路径,本来就允许多段),可磁盘上的
// **目录名**多一段就落到别处去了——所以这里还要判「只有一段」。
match crate::addon::pack::path::normalize_file(name) {
Ok(normalized) if normalized == name && !normalized.contains('/') => Ok(()),
Ok(_) => Err(SavesWriteError::new(
anchor,
SavesWriteKind::UnsafeName,
format!("名字 `{name}` 不是单段(`/` `\\` `:` `.` 段都不许出现)"),
)),
Err(error) => Err(SavesWriteError::new(
anchor,
SavesWriteKind::UnsafeName,
format!("名字 `{name}` 过不了路径规则:{error}"),
)),
}
}
/// `<run_dir>` + 世界名 → 已校验的目标(见模块文档的三道)。
pub(super) fn guard(run_dir: &Path, name: &str) -> Result<Target, SavesWriteError> {
check_name(run_dir, name)?;
let run_real = real_dir(run_dir)?;
let saves = saves_dir(run_dir);
let saves_real = real_dir(&saves)?;
// 第 2 道:`saves` 必须是**运行目录的直属子目录**(解析后比父级,不比字符串前缀)。
if saves_real.parent() != Some(run_real.as_path())
|| saves_real.file_name() != Some(OsStr::new(SAVES_DIR))
{
return Err(SavesWriteError::new(
&saves,
SavesWriteKind::Boundary,
format!(
"{} 解析到 {},不是 {} 的直属 {SAVES_DIR} 目录:拒绝沿它写盘",
saves.display(),
saves_real.display(),
run_real.display()
),
));
}
let world = saves_real.join(name);
let world_real = real_dir(&world)?;
// 第 3 道:世界目录必须**直接**挂在 `saves/` 下面(解析后比父级)。
if world_real.parent() != Some(saves_real.as_path()) {
return Err(SavesWriteError::new(
&world,
SavesWriteKind::Boundary,
format!(
"{} 解析到 {},不在 {} 之下:拒绝沿它写盘",
world.display(),
world_real.display(),
saves_real.display()
),
));
}
Ok(Target {
saves: saves_real,
world: world_real,
})
}
/// 路径 → **解析后的真实目录**(不存在 / 是链接 / 不是目录各给具名类别)。
///
/// `symlink_metadata` 先判形态,`canonicalize` 再解析:只解析不看形态的话,`canonicalize`
/// 会把链接一路跟到底,越界就变成了「正常」。
fn real_dir(path: &Path) -> Result<PathBuf, SavesWriteError> {
let meta = match std::fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(SavesWriteError::new(
path,
SavesWriteKind::NotFound,
"目录不存在",
));
}
Err(error) => return Err(SavesWriteError::io(path, &error)),
};
if meta.file_type().is_symlink() {
return Err(SavesWriteError::new(
path,
SavesWriteKind::SymbolicLink,
"是符号链接(不跟随:跟下去就动到实例目录之外的东西了)",
));
}
if !meta.is_dir() {
return Err(SavesWriteError::new(
path,
SavesWriteKind::NotADirectory,
"不是目录",
));
}
std::fs::canonicalize(path).map_err(|error| SavesWriteError::io(path, &error))
}
/// 重命名一个世界目录:`saves/<from>` → `saves/<to>`。
///
/// 返回**新的世界目录**(解析后的路径)。判据顺序见模块文档:源、目标名字、目标位置,最后才动盘。
///
/// # Errors
///
/// | 情况 | 类别 |
/// |---|---|
/// | 源不是 `saves/` 里的真目录(越界 / 链接 / 不在 / 不是目录) | `Boundary` / `SymbolicLink` / `NotFound` / `NotADirectory` |
/// | 新名字过不了名字规则 | [`SavesWriteKind::UnsafeName`] |
/// | 新名字与原名字相同,或目标位置已存在任何东西 | [`SavesWriteKind::Conflict`] |
/// | `rename` 本身失败(权限 / 被占用) | [`SavesWriteKind::Io`] |
pub fn rename_world(run_dir: &Path, from: &str, to: &str) -> Result<PathBuf, SavesWriteError> {
let target = guard(run_dir, from)?;
// 目标名字先在**已校验的 saves 目录**上查规则(锚点用 saves,比 run_dir 更贴用户看到的那一条)。
check_name(&target.saves, to)?;
let dest = target.saves.join(to);
if to == from {
return Err(SavesWriteError::new(
&dest,
SavesWriteKind::Conflict,
"新名字与原名字相同",
));
}
match std::fs::symlink_metadata(&dest) {
// 目标位置上有东西(目录 / 文件 / 坏链接)就拒:本操作不覆盖别人。
Ok(_) => {
return Err(SavesWriteError::new(
&dest,
SavesWriteKind::Conflict,
"目标位置已存在(先给它换个名字,或换一个名字给这个世界)",
));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(SavesWriteError::io(&dest, &error)),
}
std::fs::rename(&target.world, &dest)
.map_err(|error| SavesWriteError::io(&target.world, &error))?;
Ok(dest)
}
@@ -0,0 +1,90 @@
//! 递归删除一个世界目录(**不跟随符号链接**,层级有上限)。
//!
//! # 为什么不直接 `std::fs::remove_dir_all`
//!
//! 两个理由,都不是洁癖:
//!
//! 1. **要能说清「删了什么」**。`remove_dir_all` 是一次黑盒调用,失败时只给「最后一个错误」;
//! 这里逐条走,出错时能指认是哪一条路径([`SavesWriteError::path`] 就是它)。
//! 2. **要有一条自己写死的不跟随判据**。标准库文档保证 `remove_dir_all` 不跟随符号链接,
//! 但它是「实现保证」而不是「本模块的判据」——本模块把这件事写进自己的循环里:
//! `symlink_metadata` 判形态,链接只删链接本身([`remove_link`]),绝不递归进去。
//! unix 下 `remove_file` 对「指向目录的链接」也有效(unlink 删的是链接),
//! Windows 下目录链接要 `remove_dir`——两个都试一次,这就是 `remove_link` 的全部内容。
//!
//! # 层级上限
//!
//! 超过 [`MAX_DEPTH`] 层就报 [`SavesWriteKind::Boundary`]:真实的存档是
//! `world/{region,DIM-1/region,...}` 这种三到四层,六十层只可能是「这不是一个世界目录」。
//! 没有这道闸时,一次误传的路径会在删除上耗掉用户不想要的长时间(而删除是不可逆的)。
//!
//! # 边界
//!
//! 判据是「检查那一刻」的快照:检查与 `read_dir` 之间被人换掉组件(TOCTOU)看不到。
//! 本命令不是并发/对抗入口,不做跨进程锁(与 `game::adopt::run_dir` 同一条已知边界)。
use std::path::{Path, PathBuf};
use super::boundary::guard;
use super::{SavesWriteError, SavesWriteKind};
/// 递归删除的最大层级(见模块文档)。
pub const MAX_DEPTH: usize = 64;
/// 删除一个世界目录(递归),返回被删掉的那个目录(**解析后的真实路径**)。
///
/// 第一句永远是 [`guard`]:源必须是 `<run_dir>/saves` 下的真目录。越界、符号链接、
/// 不存在的目标各自给具名类别,**一个字节都不删**。
///
/// # Errors
///
/// | 情况 | 类别 |
/// |---|---|
/// | 源越界 / 是链接 / 不在 / 不是目录 | `Boundary` / `SymbolicLink` / `NotFound` / `NotADirectory` |
/// | 目录层级超过 [`MAX_DEPTH`] | [`SavesWriteKind::Boundary`] |
/// | 删某一条失败(权限 / 被占用) | [`SavesWriteKind::Io`](`path` 指出卡在哪一条) |
pub fn delete_world(run_dir: &Path, name: &str) -> Result<PathBuf, SavesWriteError> {
let target = guard(run_dir, name)?;
remove_tree(&target.world, 0)?;
Ok(target.world)
}
/// 删掉 `dir` 这一棵(`depth` 从 0 起算)。
fn remove_tree(dir: &Path, depth: usize) -> Result<(), SavesWriteError> {
if depth > MAX_DEPTH {
return Err(SavesWriteError::new(
dir,
SavesWriteKind::Boundary,
format!("目录层级超过 {MAX_DEPTH} 层,不像一个世界目录:拒绝继续往下删"),
));
}
let read = std::fs::read_dir(dir).map_err(|error| SavesWriteError::io(dir, &error))?;
for entry in read {
let entry = entry.map_err(|error| SavesWriteError::io(dir, &error))?;
let path = entry.path();
let meta = match std::fs::symlink_metadata(&path) {
Ok(meta) => meta,
// 刚被判据挡住又在两步之间消失:目标已经没了,继续删剩下的。
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(SavesWriteError::io(&path, &error)),
};
let file_type = meta.file_type();
if file_type.is_symlink() {
// **只删链接本身**:链接指向的东西不在本操作的范围里。
remove_link(&path)?;
} else if file_type.is_dir() {
remove_tree(&path, depth + 1)?;
} else {
std::fs::remove_file(&path).map_err(|error| SavesWriteError::io(&path, &error))?;
}
}
std::fs::remove_dir(dir).map_err(|error| SavesWriteError::io(dir, &error))
}
/// 删一条符号链接自身(unix 用 `remove_file`,Windows 的目录链接要用 `remove_dir`)。
fn remove_link(path: &Path) -> Result<(), SavesWriteError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(first) => std::fs::remove_dir(path).map_err(|_| SavesWriteError::io(path, &first)),
}
}
+371
View File
@@ -0,0 +1,371 @@
//! 写操作的判据:**真的在盘上建目录再动它**(不 mock 文件系统)。
//!
//! # 每个用例抓得住什么
//!
//! | 用例 | 抓得住 |
//! |---|---|
//! | [`rename_moves_only_the_named_world`] | 改名真的搬了目录、世界内容原样、别的世界不动 |
//! | [`rename_refuses_conflicts_and_bad_names`] | 撞名 / 同名 / 非法字符 / 分隔符 / 穿越全部拒,且**盘上一个字节没变** |
//! | [`traversal_is_refused_before_touching_the_disk`] | `saves/../..` 这类名字被挡在名字规则上,`saves/` 之外的东西不受影响 |
//! | [`saves_must_be_a_plain_child_of_the_run_dir`] | `saves/` 被占成文件 ⇒ 具名错误;被换成链接 ⇒ 拒(`cfg(unix)`) |
//! | [`symlinks_are_never_followed`] | 世界目录是链接 ⇒ 三个操作都拒、链接指向的那棵树**一根毫毛没少**(`cfg(unix)`) |
//! | [`nested_symlinks_are_removed_or_skipped`] | 世界**内部**的链接:删除只删链接、备份跳过并计数(`cfg(unix)`) |
//! | [`delete_removes_one_world_and_bumps_the_parent_mtime`] | 只删指名的那一个;`saves/` 的 mtime 变了、别的世界的 mtime 没变 |
//! | [`backup_writes_a_timestamped_zip`] | 时戳命名、条目与内容能读回来、目录条目也在 |
//! | [`backup_never_overwrites_an_earlier_export`] | 同一时刻两份备份 ⇒ `-2` 后缀,前一份内容不变 |
//! | [`backup_limits_are_checked_before_any_byte_is_written`] | 超限 ⇒ `TooLarge` 且落点目录**一个文件都没有** |
//! | [`missing_targets_are_named`] | 不存在的世界 / 不是目录 / 没有 `saves/` 各给具名类别 |
//!
//! # 变异自证(M247 的门禁要求)
//!
//! 把 `boundary::guard` 里那三道的**第二、三道**(`saves` 与世界的父级判据 + `real_dir` 的
//! 链接判据)删掉之后,[`symlinks_are_never_followed`] 与
//! [`saves_must_be_a_plain_child_of_the_run_dir`] 必红——报告里附的是实测输出,不是推测。
//! 只删第一道(名字规则)则 [`traversal_is_refused_before_touching_the_disk`] 与
//! [`rename_refuses_conflicts_and_bad_names`] 必红。
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use super::*;
use crate::addon::temp::TempRoot;
use crate::game::saves::{saves_dir, SAVES_DIR};
mod backup;
/// 一个只跑一帧的临时运行目录(`TempRoot::under` 返回时父级已被删,所以要自己建)。
fn run_dir(tag: &str) -> TempRoot {
let root = TempRoot::under(tag, "run");
std::fs::create_dir_all(&root).expect("建运行目录");
root
}
/// 在 `saves/` 下造一个世界(两个文件 + 两层目录)。
fn world(root: &Path, name: &str) -> PathBuf {
let dir = saves_dir(root).join(name);
std::fs::create_dir_all(dir.join("region")).expect("建世界子目录");
std::fs::write(dir.join("level.dat"), b"nbt").expect("写 level.dat");
std::fs::write(dir.join("region/r.0.0.mca"), b"chunk").expect("写 region");
dir
}
/// `(相对路径, 是否目录, 字节数, mtime 毫秒)` 的排序列表——「盘上没变」的判据本体。
fn fingerprint(root: &Path) -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(read) = std::fs::read_dir(&dir) else {
continue;
};
for entry in read.flatten() {
let path = entry.path();
let Ok(meta) = std::fs::symlink_metadata(&path) else {
continue;
};
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.display()
.to_string();
out.push(format!(
"{rel}|dir={}|len={}|mtime={}",
meta.is_dir(),
meta.len(),
meta.modified()
.ok()
.and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
.map_or(0, |since| since.as_millis())
));
if meta.is_dir() {
stack.push(path);
}
}
}
out.sort();
out
}
/// 改名真的搬了目录、世界内容原样、别的世界不动。
#[test]
fn rename_moves_only_the_named_world() {
let root = run_dir("m247-rename");
world(&root, "Alpha");
world(&root, "Beta");
let beta_before = fingerprint(&saves_dir(&root).join("Beta"));
let moved = rename_world(&root, "Alpha", "Alpha Renamed").expect("改名");
assert!(moved.ends_with("Alpha Renamed"));
assert!(!saves_dir(&root).join("Alpha").exists(), "旧目录还在");
let new = saves_dir(&root).join("Alpha Renamed");
assert_eq!(
std::fs::read(new.join("level.dat")).expect("读 level.dat"),
b"nbt",
"改名把内容搬丢了"
);
assert!(new.join("region/r.0.0.mca").exists(), "子目录没跟过来");
assert_eq!(
fingerprint(&saves_dir(&root).join("Beta")),
beta_before,
"改名动了别的世界"
);
}
/// 撞名 / 同名 / 非法字符 / 分隔符 / 穿越全部拒,且**盘上一个字节没变**。
#[test]
fn rename_refuses_conflicts_and_bad_names() {
let root = run_dir("m247-rename-bad");
world(&root, "Alpha");
world(&root, "Beta");
let before = fingerprint(&root);
let cases: &[(&str, &str, SavesWriteKind)] = &[
("Alpha", "Beta", SavesWriteKind::Conflict),
("Alpha", "Alpha", SavesWriteKind::Conflict),
("Alpha", "../escape", SavesWriteKind::UnsafeName),
("Alpha", "a/b", SavesWriteKind::UnsafeName),
("Alpha", "a\\b", SavesWriteKind::UnsafeName),
("Alpha", "C:evil", SavesWriteKind::UnsafeName),
("Alpha", "..", SavesWriteKind::UnsafeName),
("Alpha", ".", SavesWriteKind::UnsafeName),
("Alpha", "", SavesWriteKind::UnsafeName),
// 源自己也不许穿越
("../Alpha", "Other", SavesWriteKind::UnsafeName),
("saves/../Alpha", "Other", SavesWriteKind::UnsafeName),
("", "Other", SavesWriteKind::UnsafeName),
];
for (from, to, kind) in cases {
let error =
rename_world(&root, from, to).expect_err(&format!("{from} → {to:?} 居然成功了"));
assert_eq!(error.kind, *kind, "{from} → {to:?}:{}", error.detail);
}
assert_eq!(fingerprint(&root), before, "被拒的改名动了磁盘");
}
/// `saves/../..` 这类名字被挡在名字规则上,`saves/` 之外的东西不受影响。
#[test]
fn traversal_is_refused_before_touching_the_disk() {
let root = run_dir("m247-traversal");
world(&root, "Alpha");
// `saves/` 之外的哨兵:任何一次越界删除都会把它带走。
std::fs::write(root.join("sentinel.txt"), b"keep me").expect("写哨兵");
let outside = root.parent().expect("临时目录一定有父级");
let before = fingerprint(&root);
for name in [
"../..",
"..",
"../../sentinel.txt",
"saves/../..",
"/etc",
"C:/Windows",
".",
"",
] {
let error = delete_world(&root, name).expect_err(&format!("删 {name:?} 居然成功了"));
assert_eq!(
error.kind,
SavesWriteKind::UnsafeName,
"{name:?}:{}",
error.detail
);
let error = backup_world(&root, name, &root.join("exports"))
.expect_err(&format!("备份 {name:?} 居然成功了"));
assert_eq!(error.kind, SavesWriteKind::UnsafeName);
}
assert_eq!(fingerprint(&root), before, "越界尝试动了磁盘");
assert!(root.join("sentinel.txt").exists(), "哨兵被删了");
assert!(!outside.join("exports").exists(), "备份在实例外建了目录");
}
/// `saves/` 必须是运行目录下的**真目录**:被占成文件 ⇒ 具名错误;被换成链接 ⇒ 拒。
#[test]
fn saves_must_be_a_plain_child_of_the_run_dir() {
// 被占成文件
let root = run_dir("m247-saves-file");
std::fs::write(saves_dir(&root), b"not a dir").expect("用文件占住 saves");
let error = delete_world(&root, "Alpha").expect_err("saves 是文件,居然删成功了");
assert_eq!(error.kind, SavesWriteKind::NotADirectory);
// 没有 saves/
let empty = run_dir("m247-no-saves");
let error = delete_world(&empty, "Alpha").expect_err("没有 saves/,居然删成功了");
assert_eq!(error.kind, SavesWriteKind::NotFound);
// 被换成符号链接(指到实例之外)
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let linked = run_dir("m247-saves-link");
let elsewhere = linked.parent().expect("父级").join("elsewhere-saves");
std::fs::create_dir_all(elsewhere.join("Alpha")).expect("建实例外世界");
std::fs::write(elsewhere.join("Alpha/level.dat"), b"nbt").expect("写实例外 level.dat");
symlink(&elsewhere, saves_dir(&linked)).expect("建 saves 链接");
let error = delete_world(&linked, "Alpha").expect_err("saves 是链接,居然删成功了");
assert_eq!(error.kind, SavesWriteKind::SymbolicLink, "{}", error.detail);
assert!(
elsewhere.join("Alpha/level.dat").exists(),
"顺着 saves 链接把实例外的世界删了"
);
}
}
/// 世界目录是链接 ⇒ 三个操作都拒、链接指向的那棵树一根毫毛没少(`cfg(unix)`)。
#[cfg(unix)]
#[test]
fn symlinks_are_never_followed() {
use std::os::unix::fs::symlink;
let root = run_dir("m247-symlink-world");
std::fs::create_dir_all(saves_dir(&root)).expect("建 saves");
let elsewhere = root.parent().expect("父级").join("shared-world");
std::fs::create_dir_all(elsewhere.join("region")).expect("建实例外世界");
std::fs::write(elsewhere.join("level.dat"), b"nbt").expect("写 level.dat");
symlink(&elsewhere, saves_dir(&root).join("Linked")).expect("建世界链接");
let before = fingerprint(&elsewhere);
for error in [
rename_world(&root, "Linked", "Copied").expect_err("链接世界居然改名成功了"),
delete_world(&root, "Linked").expect_err("链接世界居然删成功了"),
backup_world(&root, "Linked", &root.join("exports")).expect_err("链接世界居然备份成功了"),
] {
assert_eq!(error.kind, SavesWriteKind::SymbolicLink, "{}", error.detail);
}
assert_eq!(fingerprint(&elsewhere), before, "跟着链接动了实例外的目录");
}
/// Windows 上「不跟随链接」的可执行证据:**目录联接(junction)不需要管理员权限**,
/// 所以这条判据在 Windows 上真的跑得起来(`symlink_dir` 要提权 / 开发者模式,跑不了)。
///
/// 判据是「拒 + 实例外那棵树一根毫毛没少」;具体的具名类别两档都算过:
/// 联接被 `symlink_metadata` 认成链接 ⇒ `SymbolicLink`(**M247 实测就是这一档**);
/// 只被解析成目录 ⇒「`saves` 的直属子目录」那道父级判据把它拦成 `Boundary`。
#[cfg(windows)]
#[test]
fn junctions_are_never_followed() {
let root = run_dir("m247-junction-world");
std::fs::create_dir_all(saves_dir(&root)).expect("建 saves");
let elsewhere = root.parent().expect("父级").join("shared-junction");
std::fs::create_dir_all(elsewhere.join("region")).expect("建实例外世界");
std::fs::write(elsewhere.join("level.dat"), b"nbt").expect("写 level.dat");
let link = saves_dir(&root).join("Linked");
// `quiet` 不是洁癖:Windows 上裸 `Command::new` 会给每个子进程开一块控制台,
// GUI 父进程下就是桌面上「黑框闪一下」(`tests/child_quiet_guard.rs` 盯着这一条)。
let status = crate::platform::child::quiet("cmd")
.args(["/C", "mklink", "/J"])
.arg(&link)
.arg(&elsewhere)
.output()
.expect("拉起 mklink");
assert!(
status.status.success(),
"mklink /J 失败:{}",
String::from_utf8_lossy(&status.stderr)
);
let before = fingerprint(&elsewhere);
let error = delete_world(&root, "Linked").expect_err("联接世界居然删成功了");
assert!(
matches!(
error.kind,
SavesWriteKind::SymbolicLink | SavesWriteKind::Boundary
),
"联接被当成普通目录放过了:{:?} / {}",
error.kind,
error.detail
);
let error = backup_world(&root, "Linked", &root.join("exports")).expect_err("居然备份了联接");
assert!(matches!(
error.kind,
SavesWriteKind::SymbolicLink | SavesWriteKind::Boundary
));
assert_eq!(fingerprint(&elsewhere), before, "跟着联接动了实例外的目录");
// 收尾:先摘掉联接,别让临时根的清理去碰它(`TempRoot` 的 Drop 在下面自己删)。
let _ = std::fs::remove_dir(&link);
}
/// 世界**内部**的链接:删除只删链接、备份跳过并计数(`cfg(unix)`)。
#[cfg(unix)]
#[test]
fn nested_symlinks_are_removed_or_skipped() {
use std::os::unix::fs::symlink;
let root = run_dir("m247-nested-link");
let dir = world(&root, "Alpha");
let target = root.join("outside.bin");
std::fs::write(&target, b"outside").expect("写实例外文件");
symlink(&target, dir.join("region/link.bin")).expect("建世界内链接");
let exports = root.join("exports");
let backup = backup_world(&root, "Alpha", &exports).expect("备份");
assert_eq!(backup.skipped, 1, "世界内的链接要跳过并计数");
delete_world(&root, "Alpha").expect("删世界");
assert!(!dir.exists(), "世界目录还在");
assert!(target.exists(), "删世界时把链接指向的实例外文件也删了");
}
/// 只删指名的那一个;`saves/` 的 mtime 变了、别的世界的 mtime 没变。
#[test]
fn delete_removes_one_world_and_bumps_the_parent_mtime() {
let root = run_dir("m247-delete");
world(&root, "Alpha");
world(&root, "Beta");
let saves = saves_dir(&root);
let parent_before = mtime(&saves);
let beta_before = mtime(&saves.join("Beta"));
let beta_tree = fingerprint(&saves.join("Beta"));
// mtime 的粒度可能是一秒(FAT / 一些网络盘),睡一下再删,判据才有分辨力。
std::thread::sleep(Duration::from_millis(20));
delete_world(&root, "Alpha").expect("删世界");
assert!(!saves.join("Alpha").exists(), "世界目录还在");
assert_eq!(
fingerprint(&saves.join("Beta")),
beta_tree,
"删除动了别的世界"
);
assert_eq!(
mtime(&saves.join("Beta")),
beta_before,
"别的世界的 mtime 变了"
);
assert!(
mtime(&saves) >= parent_before,
"saves/ 的 mtime 应该跟着变({} → {})",
parent_before,
mtime(&saves)
);
}
/// 不存在的世界 / 不是目录 / 没有 `saves/` 各给具名类别。
#[test]
fn missing_targets_are_named() {
let root = run_dir("m247-missing");
world(&root, "Alpha");
std::fs::write(saves_dir(&root).join("loose.txt"), b"x").expect("写散落文件");
let error = delete_world(&root, "Nope").expect_err("不存在的世界居然删成功了");
assert_eq!(error.kind, SavesWriteKind::NotFound);
let error = delete_world(&root, "loose.txt").expect_err("文件居然被当成世界删了");
assert_eq!(error.kind, SavesWriteKind::NotADirectory);
let error = backup_world(&root, "Nope", &root.join("exports2")).expect_err("居然备份成功");
assert_eq!(error.kind, SavesWriteKind::NotFound);
assert!(
saves_dir(&root).join("loose.txt").exists(),
"散落文件被动了"
);
assert_eq!(
saves_dir(&root).file_name().unwrap().to_string_lossy(),
SAVES_DIR,
"路径拼接口径"
);
}
/// mtime(毫秒;取不到给 0)。
fn mtime(path: &Path) -> u128 {
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|time| time.duration_since(SystemTime::UNIX_EPOCH).ok())
.map_or(0, |since| since.as_millis())
}
@@ -0,0 +1,154 @@
//! 备份(zip 导出)那几条判据——从 `write/tests.rs` 拆出:那边加完 11 条用例之后越过 400 行硬线
//! (拆法与仓库其它 `-p1/-p2` / `tail*` 一致:**纯搬移,判据一条不改**)。
//!
//! | 用例 | 抓得住 |
//! |---|---|
//! | [`backup_writes_a_timestamped_zip`] | 时戳命名、条目与内容能读回来、空目录条目也在 |
//! | [`backup_never_overwrites_an_earlier_export`] | 同一时刻两份备份 ⇒ `-2` 后缀,前一份不变 |
//! | [`backup_limits_are_checked_before_any_byte_is_written`] | 超限 ⇒ `TooLarge` 且落点目录一个文件都没有 |
//! | [`backup_refuses_a_relative_or_empty_dest`] | 空 / 相对的落点 ⇒ `Boundary`(相对路径会落到进程当前目录) |
use std::io::Read;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use super::super::backup::{backup_world_at, stamp, Limits};
use super::super::*;
use super::{fingerprint, run_dir, world};
/// 2024-05-01 10:00:00 UTC(写死的时刻:时戳命名要能逐字断言)。
fn fixed_at() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_millis(1_714_557_600_000)
}
/// 时戳命名、条目与内容能读回来、目录条目也在。
#[test]
fn backup_writes_a_timestamped_zip() {
let root = run_dir("m247-backup");
let dir = world(&root, "Alpha");
std::fs::create_dir_all(dir.join("empty-dir")).expect("建空目录");
let exports = root.join("exports");
let backup =
backup_world_at(&root, "Alpha", &exports, fixed_at(), Limits::production()).expect("备份");
assert_eq!(
backup.path.file_name().unwrap().to_string_lossy(),
"Alpha-20240501-100000.zip",
"落点名要带 UTC 时戳"
);
assert_eq!(backup.skipped, 0);
assert!(backup.bytes > 0);
assert_eq!(backup.sourced, 8, "level.dat + region 共 8 字节");
assert_eq!(
backup.entries, 4,
"region/ 与 empty-dir/ 两个目录条目 + 两个文件条目"
);
let file = std::fs::File::open(&backup.path).expect("开备份");
let mut archive = zip::ZipArchive::new(file).expect("读 zip");
let names: Vec<String> = (0..archive.len())
.map(|index| {
archive
.by_index(index)
.expect("读条目")
.name()
.expect("条目名")
.to_string()
})
.collect();
assert!(names.contains(&"level.dat".to_string()), "条目:{names:?}");
assert!(
names.contains(&"region/r.0.0.mca".to_string()),
"条目:{names:?}"
);
assert!(
names.contains(&"empty-dir/".to_string()),
"空目录丢了:{names:?}"
);
let mut body = Vec::new();
archive
.by_name("level.dat")
.expect("取 level.dat")
.read_to_end(&mut body)
.expect("读 level.dat");
assert_eq!(body, b"nbt", "包里的内容与盘上不一致");
// 时戳换算本身(同一张固定时刻表)。
assert_eq!(stamp(fixed_at()), "20240501-100000");
}
/// 同一时刻两份备份 ⇒ `-2` 后缀,前一份内容不变。
#[test]
fn backup_never_overwrites_an_earlier_export() {
let root = run_dir("m247-backup-twice");
world(&root, "Alpha");
let exports = root.join("exports");
let first = backup_world_at(&root, "Alpha", &exports, fixed_at(), Limits::production())
.expect("第一次");
let body = std::fs::read(&first.path).expect("读第一份");
let second = backup_world_at(&root, "Alpha", &exports, fixed_at(), Limits::production())
.expect("第二次");
assert_ne!(first.path, second.path, "第二份覆盖了第一份");
assert_eq!(
second.path.file_name().unwrap().to_string_lossy(),
"Alpha-20240501-100000-2.zip"
);
assert_eq!(
std::fs::read(&first.path).expect("读第一份"),
body,
"第一份被改了"
);
}
/// 超限 ⇒ `TooLarge` 且落点目录**一个文件都没有**(闸在第一个字节之前)。
#[test]
fn backup_limits_are_checked_before_any_byte_is_written() {
let root = run_dir("m247-backup-limit");
world(&root, "Alpha");
let exports = root.join("exports");
// 总量闸:世界有 8 字节,上限给 4。
let error = backup_world_at(&root, "Alpha", &exports, fixed_at(), Limits::new(4, 100))
.expect_err("超限居然备份成功了");
assert_eq!(error.kind, SavesWriteKind::TooLarge, "{}", error.detail);
assert!(!exports.exists(), "超限时连落点目录都不该建");
// 条目闸:4 个条目 + 上限给 2。
let error = backup_world_at(
&root,
"Alpha",
&exports,
fixed_at(),
Limits::new(1 << 30, 2),
)
.expect_err("条目超限居然备份成功了");
assert_eq!(error.kind, SavesWriteKind::TooLarge);
assert!(
!exports.exists()
|| std::fs::read_dir(&exports)
.expect("读落点")
.next()
.is_none(),
"超限时写出了文件"
);
}
/// 落点目录必须是绝对路径:相对路径会落到进程当前目录(一次实测里 `dest = ""` 把 zip
/// 写进了 crate 根),所以「空 / 相对」直接拒。
#[test]
fn backup_refuses_a_relative_or_empty_dest() {
let root = run_dir("m247-backup-dest");
world(&root, "Alpha");
let before = fingerprint(&root);
for dest in [
PathBuf::new(),
PathBuf::from("exports"),
PathBuf::from("./exports"),
] {
let error = backup_world(&root, "Alpha", &dest).expect_err("相对落点居然备份成功了");
assert_eq!(error.kind, SavesWriteKind::Boundary, "{}", error.detail);
}
assert_eq!(fingerprint(&root), before, "被拒的备份动了磁盘");
}