Files
Unlucky-Launcher/crates/core/tests/adopt/snapshot.rs
T
AskaEth 8110cde3f5 fix(game): 复审 r1 五项——运行目录不许被两实例共用 + 配置写面越界 + versions 断链 + 链接守卫反转 + 快照漏目录
复审 p2-5items 的五项,全部按「先能红的用例,再改实现」:

R1(默认目录可被不同实例隐式共用,产品缺陷):新增 `adopt/run_dir.rs`——登记前读**每个已有实例**
的 `gameDir`(现役设置 + M27 前档案的遗留字段),用「真实路径 + 拼回不存在的尾段 + 大小写归一」
(Windows/macOS)比较;撞上就顺延 `<base>-2/-3…`(上限 32,撞满 `adopt_no_free_run_dir`)并在
`notes` 点名撞的是哪个实例;**非空且无人认领**仍拒绝(那是用户数据,不偷偷换目录)。这样
`Better MC` 与 `Better-MC`(slug 同名)、Windows 上 `Foo` 与 `foo`(同目录)都不会共用一个运行目录。
大小写归一在 macOS 上是**保守**选择:大小写敏感卷上会多一次顺延,换来的是「绝不共用」在任何卷上成立。

R2(配置写面 junction 写穿到外部游戏根,产品缺陷):`run_dir::ensure_config_write_surface()`
在 `plan`(dry-run 也报)与 `adopt`(写盘前)各核一次——`config/`、`config/instances/`、
`profiles.json` 的真实落点必须在 `<.ULL>` 内,且**不得经链接落进游戏根**(错误按真实归属分类:
「逃出 .ULL」vs「落进游戏根」)。`.ULL` 本身在游戏根内是布局事实,附加判据在该布局下零判别力,
主判据仍然拦下一切跑出 `.ULL` 的落点——这不是给链接开后门。新码 `adopt_config_surface_unsafe`。

R3(dangling versions 被当不存在,产品缺陷):`scan` 的 `read_dir` `NotFound` 再三分——
`lstat(versions)` 看见入口(断链符号链接/目录联接/目标被移走)或游戏根解析失败(祖先断链)⇒
`adopt_versions_unreadable`;两者都不是才算「真没有」。`inspect` 也补了同族判据(条目存在但
不是可读目录 ⇒ `directory_unreadable`,不再混成「没装」)。

R4(强制链接测试逻辑反转,假绿):`fixture::link_gate(created, required) -> Run|Skip|Fail` 是纯
真值表,`link_guard_with()` 显式传参(`link_guard()` 只读环境变量);新增
`link_guard_executes_all_three_branches` 用 `catch_unwind` **执行** Fail/Skip 两个分支。
四处调用点统一走 guard;建链接/联接的夹具收进 `fixture.rs`。

R5(快照遗漏目录节点,假绿):新增 `tests/adopt/snapshot.rs`——全节点快照(根/目录/文件/链接
各记类型 + 适用 mtime;目录记 mtime、链接只记目标不跟随、Windows 用重解析点属性认 junction、
读盘失败直接 panic)。**CLI 测试用 `#[path]` 复用同一份**,判据只一处;新增 `fixture_cases`
五条工具自测,`plan`/dry-run 的零写盘判据改用 `snapshot_opt`(连空目录都不许出现)。

验证(本轮持久日志,`target/m221-r1/`,git 忽略、不入库):
- `cargo fmt --all --check` 0;`cargo check -p unlucky-core -p unlucky-cli --all-targets` 0;
- `cargo test -p unlucky-core --test adopt` **37 passed / 0 failed**(另跑一次
  `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 同样 37 passed);
- `cargo test -p unlucky-cli --test adopt_test` **11 passed / 0 failed**;
- `cargo clippy -p unlucky-core -p unlucky-cli --all-targets -- -D warnings` 0;
- 四次**实现-only 变异**(缺省不查认领 / 配置写面不核 / versions 的 NotFound 当空 / 快照不记目录)
  全部以**断言红**收场(不是编译错误、不是环境错误),每次还原后与基线清单逐文件一致,收尾再跑绿。

界限:只覆盖 core+cli 的定向目标(不等于 workspace 全量);没有启动真实 Minecraft;GUI 未接;
判据是检查那一刻的快照,TOCTOU 与非 junction 重解析点仍列边界。
2026-11-09 23:13:57 +08:00

181 lines
6.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 目录树**全节点**快照:目录 / 文件 / 链接各记自己的类型与适用 mtime。
//!
//! # 为什么不是「只记文件」
//!
//! 复审 r1 的 R5:早先版本遇到目录只递归、不记录,于是「登记过程凭空建了一个空目录」
//! (例如错误地在 `<game>/versions/<v>/mods` 里 `create_dir_all`)在快照上**完全看不出来**。
//! 「只读外部」这条承诺必须能抓住新增/删除的**目录节点**,所以这里:
//!
//! - 根自身、每个目录、每个文件、每个链接都进快照(相对路径 → [`Node`]);
//! - 目录记 mtime(条目增删会改它),文件记字节数 + mtime + 内容哈希;
//! - **链接不跟随**:只记目标路径(跟随会把根外的内容算进「外部树」,让越界写入看起来像「没变化」);
//! Windows 的目录联接(junction)不是 `is_symlink`,这里额外用重解析点属性识别它;
//! - 任何读盘失败都**直接失败**(`panic`),不吞成「这项没变」——吞错就是假绿。
//!
//! # 为什么 CLI 测试也用这一份
//!
//! 以前 core 与 CLI 各写一份 walk,两份都漏了目录节点(同一处缺口两遍)。现在 CLI 的
//! `adopt_test/fixture.rs` 用 `#[path = "../../../core/tests/adopt/snapshot.rs"]` 复用本文件:
//! **判据只有一份**,不会再次漂移。
use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
/// 快照里的一个节点。
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Node {
/// 目录(只记 mtime:条目增删会改它)。
Dir {
/// mtime(纳秒;取不到记 0)。
mtime_ns: u128,
},
/// 文件(字节数 + mtime + 内容哈希)。
File {
/// 字节数。
len: u64,
/// mtime(纳秒)。
mtime_ns: u128,
/// 内容哈希(稳定即可,不要密码学强度)。
hash: u64,
},
/// 符号链接 / Windows 目录联接:只记目标,**不跟随**。
Link {
/// 链接目标(原样,可能是相对路径;读不出来时记 `<unreadable-link>`)。
target: PathBuf,
},
}
/// 一次快照:相对路径(根自身是 `.`)→ 节点。
pub type Snapshot = BTreeMap<String, Node>;
/// 拍一次全节点快照(目标不存在 = 空快照,见 [`snapshot_opt`] 的区分)。
///
/// # Panics
///
/// 任何目录/元数据/文件读不出来都 panic:读不出来不能当成「没变化」。
pub fn snapshot(root: &Path) -> Snapshot {
snapshot_opt(root).unwrap_or_default()
}
/// 同 [`snapshot`],但目标**不存在**时返回 `None`(用于区分「空树」与「根本没有」)。
///
/// # Panics
///
/// 同上(存在但读不出来即 panic)。
pub fn snapshot_opt(root: &Path) -> Option<Snapshot> {
match std::fs::symlink_metadata(root) {
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
Err(error) => panic!("快照根读不出来 {}:{error}", root.display()),
}
let mut out = Snapshot::new();
out.insert(".".to_string(), node_of(root));
walk(root, root, &mut out);
Some(out)
}
fn walk(root: &Path, dir: &Path, out: &mut Snapshot) {
let entries = std::fs::read_dir(dir)
.unwrap_or_else(|error| panic!("目录应可读 {}:{error}", dir.display()));
for entry in entries {
let entry = entry.unwrap_or_else(|error| panic!("目录项应可读 {}:{error}", dir.display()));
let path = entry.path();
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let node = node_of(&path);
let is_dir = matches!(node, Node::Dir { .. });
out.insert(relative, node);
if is_dir {
walk(root, &path, out);
}
}
}
/// 单个节点的形状(链接/联接**不跟随**)。
fn node_of(path: &Path) -> Node {
let meta = std::fs::symlink_metadata(path)
.unwrap_or_else(|error| panic!("元数据应可读 {}:{error}", path.display()));
if link_like(&meta) {
let target =
std::fs::read_link(path).unwrap_or_else(|_| PathBuf::from("<unreadable-link>"));
return Node::Link { target };
}
let mtime_ns = meta
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map_or(0, |duration| duration.as_nanos());
if meta.file_type().is_dir() {
return Node::Dir { mtime_ns };
}
let bytes = std::fs::read(path)
.unwrap_or_else(|error| panic!("文件应可读 {}:{error}", path.display()));
Node::File {
len: bytes.len() as u64,
mtime_ns,
hash: hash(&bytes),
}
}
/// 符号链接(所有平台)或 Windows 重解析点(junction / mount point)。
///
/// `FileType::is_symlink` 对 junction 返回 `false`,所以 Windows 上另看
/// `FILE_ATTRIBUTE_REPARSE_POINT`:不识别它就会把 junction 当普通目录**递归进去**,
/// 等于跟随链接读根外内容。
fn link_like(meta: &std::fs::Metadata) -> bool {
if meta.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
{
false
}
}
/// 内容哈希(只要稳定、不需要密码学强度)。
fn hash(bytes: &[u8]) -> u64 {
let mut hasher = DefaultHasher::new();
bytes.hash(&mut hasher);
hasher.finish()
}
/// 断言两次快照**完全一致**:路径集合(含目录/链接)与每个节点的形状都不许变。
///
/// 抓得住:
/// - 新增/删除**空目录**(目录节点进快照)、目录条目变化(目录 mtime 变);
/// - 文件被改写(哈希变、mtime 变)或被删(路径集合变);
/// - 链接目标被改(`Link.target` 变)。
///
/// 抓不住:只读打开(无可观测痕迹)、文件内容相同且 mtime 未被改写的「伪写」、
/// 以及检查期间的 TOCTOU。
///
/// # Panics
///
/// 快照不一致即 panic(消息里点名第一个不同的路径)。
pub fn assert_unchanged(before: &Snapshot, after: &Snapshot, what: &str) {
assert_eq!(
before.keys().collect::<Vec<_>>(),
after.keys().collect::<Vec<_>>(),
"{what}:路径集合(含目录/链接节点)变了"
);
for (path, node) in before {
assert_eq!(
node,
after.get(path).expect("同一路径"),
"{what}:节点被改动 -> {path}"
);
}
}