复审 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 重解析点仍列边界。
181 lines
6.7 KiB
Rust
181 lines
6.7 KiB
Rust
//! 目录树**全节点**快照:目录 / 文件 / 链接各记自己的类型与适用 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}"
|
||
);
|
||
}
|
||
}
|