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:
1 parent
3e852331f4
commit
c921b9c185
28 files changed
+3016
-216
No files matched your search
@@ -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;
|
||||
@@ -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 => "系统错误",
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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, "被拒的备份动了磁盘");
|
||||
}
|
||||
Reference in new issue
Block a user