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 重解析点仍列边界。
This commit is contained in:
1 parent
9f5c001387
commit
8110cde3f5
19 files changed
+1363
-272
No files matched your search
@@ -0,0 +1,144 @@
|
||||
//! 运行目录**碰撞**:slug 同名 / 大小写不敏感 / 「空目录已被实例占着」/ 「认领目录还不存在」。
|
||||
//!
|
||||
//! 复审 r1 的 R1:旧实现只比展示名,第二次登记看到「目录是空的」就复用了 ⇒ 两个从未选择
|
||||
//! shared/custom 的实例共用一个运行目录(`mods`/`saves` 互相污染)。
|
||||
//! 判据必须是「有没有实例占着这个**真实**目录」,而不是「目录空不空」。
|
||||
|
||||
use unlucky_core::game::adopt::{self, AdoptTarget};
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
use unlucky_core::game::install::pack_dir;
|
||||
|
||||
use crate::fixture::{foreign_minecraft, request, TempRoot};
|
||||
|
||||
/// **slug 碰撞**:`Better MC` 与 `Better-MC` 派生到同一个目录名 —— 两个实例必须落在不同目录。
|
||||
///
|
||||
/// 复审 r1 的 R1:旧实现只比展示名,第二次登记看到「目录是空的」就复用了,于是两个从未选择
|
||||
/// shared/custom 的实例共用一个运行目录(`mods`/`saves` 互相污染)。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - 判据只看目录空不空、不看已有实例占没占(两条 `run_dir` 不同的断言会红);
|
||||
/// - 顺延目录没建出来 / 提示里没点出撞的是谁。
|
||||
///
|
||||
/// 抓不住:`pack_dir::instance_dir_name` 本身把两个名字折成同一个目录(那是既有行为,
|
||||
/// 本 mission 不改它,只保证**登记侧**不共用)。
|
||||
#[test]
|
||||
fn slug_colliding_names_do_not_share_a_run_dir() {
|
||||
let root = TempRoot::new("slug-collide");
|
||||
foreign_minecraft(&root.game_root());
|
||||
|
||||
let mut first = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
first.name = Some("Better MC".to_string());
|
||||
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("第一次登记");
|
||||
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
second.name = Some("Better-MC".to_string());
|
||||
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记");
|
||||
|
||||
assert_ne!(a.run_dir, b.run_dir, "两个实例不许共用同一个运行目录");
|
||||
assert!(
|
||||
a.run_dir.is_dir() && b.run_dir.is_dir(),
|
||||
"两个目录都要真实存在"
|
||||
);
|
||||
let bumped = b
|
||||
.run_dir
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned());
|
||||
assert!(
|
||||
bumped.as_deref().is_some_and(|name| name.ends_with("-2")),
|
||||
"撞车后应顺延成 -2:{:?}",
|
||||
b.run_dir
|
||||
);
|
||||
assert!(
|
||||
b.notes.iter().any(|note| note.contains("同一个物理目录")),
|
||||
"必须把「撞了谁」说出来:{:?}",
|
||||
b.notes
|
||||
);
|
||||
|
||||
// 两份实例设置各自指向自己的目录(不是都指第一个)。
|
||||
let config_a = read_instance_config(&root.launcher(), &a.instance_id)
|
||||
.expect("读设置")
|
||||
.expect("有设置文件");
|
||||
let config_b = read_instance_config(&root.launcher(), &b.instance_id)
|
||||
.expect("读设置")
|
||||
.expect("有设置文件");
|
||||
assert_eq!(config_a.game_dir.as_deref(), Some(a.run_dir.as_path()));
|
||||
assert_eq!(config_b.game_dir.as_deref(), Some(b.run_dir.as_path()));
|
||||
}
|
||||
|
||||
/// **大小写碰撞**(Windows/macOS):`Foo` 与 `foo` 是同一个物理目录 ⇒ 同样必须顺延。
|
||||
///
|
||||
/// 抓得住:`dir_key` 不做大小写归一(Windows 上第二次登记会复用同一个目录)。
|
||||
/// 抓不住:Linux(大小写敏感,两条目录本来就不同——那里这条不适用,见 `cfg`)。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn names_differing_only_in_case_do_not_share_a_run_dir() {
|
||||
let root = TempRoot::new("case-collide");
|
||||
foreign_minecraft(&root.game_root());
|
||||
|
||||
let mut first = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
first.name = Some("Foo".to_string());
|
||||
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("第一次登记");
|
||||
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
second.name = Some("foo".to_string());
|
||||
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记");
|
||||
|
||||
assert_ne!(a.run_dir, b.run_dir, "大小写不同不是「不同目录」的理由");
|
||||
let a_real = a.run_dir.canonicalize().expect("真实路径 A");
|
||||
let b_real = b.run_dir.canonicalize().expect("真实路径 B");
|
||||
assert_ne!(a_real, b_real, "两个运行目录的真实路径必须不同");
|
||||
}
|
||||
|
||||
/// 空目录**已被别的实例占着** ⇒ 也不能当「失败残留」复用,要顺延。
|
||||
///
|
||||
/// 这是 R1 的另一半:判据必须是「有没有实例占着」,而不是「目录空不空」。
|
||||
///
|
||||
/// 抓得住:只按 `directory_occupied` 判(第一次登记建出的空目录会被第二次白白复用)。
|
||||
/// 抓不住:目录非空且无人认领的情形(那条保持 `TargetNotEmpty`,见下一条用例)。
|
||||
#[test]
|
||||
fn an_empty_dir_owned_by_another_instance_is_not_reused() {
|
||||
let root = TempRoot::new("owned-empty");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let mut first = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
first.name = Some("Better MC".to_string());
|
||||
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("第一次登记");
|
||||
// 目录此刻是空的(新建、还没启动过游戏)——旧判据会认为「可复用」。
|
||||
let entries_before = std::fs::read_dir(&a.run_dir).expect("读目录").count();
|
||||
assert_eq!(entries_before, 0, "夹具前提:这个目录此刻是空的");
|
||||
|
||||
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
second.name = Some("Better-MC".to_string());
|
||||
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记");
|
||||
assert_ne!(b.run_dir, a.run_dir);
|
||||
}
|
||||
|
||||
/// **认领的目录还不存在**时也必须能比对出碰撞。
|
||||
///
|
||||
/// 场景:第一个实例用 `--isolation custom --game-dir <某路径>` 登记(custom 档**不创建**目录),
|
||||
/// 那条路径恰好等于第二个实例缺省派生出来的目录名。第二个实例看到的目录「不存在、非空判据为假」,
|
||||
/// 但它已经被第一个实例认领了。
|
||||
///
|
||||
/// 抓得住:`claims`/`dir_key` 只在「路径存在」时才比较(那样第二条 `run_dir` 相同的断言会红);
|
||||
/// 抓不住:跨卷/大小写之外的别名(例如 8.3 短名),本判据不覆盖。
|
||||
#[test]
|
||||
fn a_claim_on_a_not_yet_existing_dir_still_collides() {
|
||||
let root = TempRoot::new("claim-missing");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let default_dir = pack_dir::instance_dir(&root.launcher(), "Better MC");
|
||||
assert!(!default_dir.exists(), "夹具前提:这个目录还不存在");
|
||||
|
||||
let mut first = request("1.20.1", AdoptTarget::Custom(default_dir.clone()));
|
||||
first.name = Some("Better MC".to_string());
|
||||
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("custom 登记成功");
|
||||
assert_eq!(a.run_dir, default_dir);
|
||||
assert!(!default_dir.exists(), "custom 目标不该被创建");
|
||||
|
||||
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
second.name = Some("Better-MC".to_string());
|
||||
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记成功");
|
||||
|
||||
assert_ne!(b.run_dir, default_dir, "认领侧不存在也要能比对出碰撞");
|
||||
assert!(
|
||||
b.notes.iter().any(|note| note.contains("同一个物理目录")),
|
||||
"碰撞要写进 notes:{:?}",
|
||||
b.notes
|
||||
);
|
||||
}
|
||||
@@ -19,16 +19,18 @@
|
||||
//! | `libraries/` `assets/` | 恒共享层(登记不该碰) |
|
||||
//! | `mods/` `saves/` `config/` | 玩家数据(登记不该碰) |
|
||||
//! | `launcher_profiles.json` | 别的启动器的元数据(连读都不该读,更不该写) |
|
||||
//!
|
||||
//! 快照工具在 `snapshot.rs`(**全节点**:目录/文件/链接各记形状与 mtime,读盘失败即失败),
|
||||
//! 本模块只做 `pub use`,测试统一从 `crate::fixture::{snapshot, assert_unchanged, …}` 取。
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use unlucky_core::game::adopt::{AdoptRequest, AdoptTarget};
|
||||
use unlucky_core::game::GameLayout;
|
||||
|
||||
pub use super::snapshot::{assert_unchanged, snapshot, snapshot_opt, Node};
|
||||
use unlucky_core::platform::layout::LauncherLayout;
|
||||
|
||||
/// 唯一临时目录守卫(`drop` 时递归删除)。
|
||||
@@ -119,68 +121,6 @@ pub fn foreign_minecraft(root: &Path) {
|
||||
);
|
||||
}
|
||||
|
||||
/// 一层文件的**全量**快照:相对路径 → (字节数, mtime 纳秒, 内容哈希)。
|
||||
///
|
||||
/// 三个维度都要:只看路径集合抓不住「内容被重写但文件名没变」,只看内容抓不住「mtime 被改写」
|
||||
/// (M216 的「相同内容一个字节都不写」正是用 mtime 判的)。
|
||||
pub fn snapshot(dir: &Path) -> BTreeMap<String, (u64, u128, u64)> {
|
||||
let mut out = BTreeMap::new();
|
||||
walk(dir, dir, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, (u64, u128, u64)>) {
|
||||
let entries = std::fs::read_dir(dir).expect("目录应可读");
|
||||
for entry in entries {
|
||||
let entry = entry.expect("目录项应可读");
|
||||
let path = entry.path();
|
||||
let meta = std::fs::symlink_metadata(&path).expect("元数据应可读");
|
||||
if meta.is_dir() {
|
||||
walk(root, &path, out);
|
||||
continue;
|
||||
}
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.expect("相对路径")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let bytes = std::fs::read(&path).expect("文件应可读");
|
||||
let mtime = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map_or(0, |duration| duration.as_nanos());
|
||||
out.insert(relative, (bytes.len() as u64, mtime, hash(&bytes)));
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容哈希(只要稳定、不需要密码学强度)。
|
||||
fn hash(bytes: &[u8]) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
bytes.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// 断言两次快照**完全一致**:路径集合、每个文件的字节数、内容哈希、mtime 全都不许变。
|
||||
///
|
||||
/// 抓得住:登记过程中写了/删了/改了任何外部文件(含「内容相同但重写了一遍」——mtime 会变)。
|
||||
/// 抓不住:只读打开(不产生可观测痕迹)以及目录顺序之类查不到的东西。
|
||||
pub fn assert_unchanged(
|
||||
before: &BTreeMap<String, (u64, u128, u64)>,
|
||||
after: &BTreeMap<String, (u64, u128, u64)>,
|
||||
what: &str,
|
||||
) {
|
||||
assert_eq!(
|
||||
before.keys().collect::<Vec<_>>(),
|
||||
after.keys().collect::<Vec<_>>(),
|
||||
"{what}:路径集合变了"
|
||||
);
|
||||
for (path, before) in before {
|
||||
let after = after.get(path).expect("同一路径");
|
||||
assert_eq!(before, after, "{what}:文件被改动 -> {path}");
|
||||
}
|
||||
}
|
||||
|
||||
fn version_with(versions: &Path, id: &str, manifest: &str, jar: bool) {
|
||||
let dir = versions.join(id);
|
||||
std::fs::create_dir_all(&dir).expect("建版本目录");
|
||||
@@ -249,13 +189,33 @@ const MANIFEST_CYCLE_B: &str = r#"{
|
||||
"libraries": []
|
||||
}"#;
|
||||
|
||||
/// 一次登记请求(测试用:固定 `created_at`,名字缺省 = 版本 id)。
|
||||
pub fn request(version_id: &str, target: AdoptTarget) -> AdoptRequest {
|
||||
AdoptRequest {
|
||||
version_id: version_id.to_string(),
|
||||
name: None,
|
||||
target,
|
||||
created_at: Some("2026-11-09T00:00:00.000Z".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 建目录符号链接(`true` = 建成)。Windows 上未开开发者模式/非管理员时可能失败。
|
||||
pub fn try_symlink_dir(target: &Path, link: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
let created = std::os::unix::fs::symlink(target, link).is_ok();
|
||||
#[cfg(windows)]
|
||||
let created = std::os::windows::fs::symlink_dir(target, link).is_ok();
|
||||
created
|
||||
}
|
||||
|
||||
/// 用 `mklink /J` 建目录联接(junction 不需要管理员权限;建不出来返回 `false`)。
|
||||
///
|
||||
/// 只在 Windows 上有意义:junction 不是 `is_symlink`,是把别人版本目录挂进 `versions/` 最常见的形态,
|
||||
/// 所以越界判据必须能看见它——这条夹具就是用来证明这点的。
|
||||
/// 只在 Windows 上有意义:junction 不是 `is_symlink`,是把别人版本目录挂进 `versions/` 最常见的
|
||||
/// 形态,所以越界判据必须能看见它——这条夹具就是用来证明这点的。
|
||||
#[cfg(windows)]
|
||||
pub fn try_junction(target: &Path, link: &Path) -> bool {
|
||||
// `cmd /c mklink /J <link> <target>`:三个参数分开传,别拼成一整条(拼成一串会被 cmd 当语法错)。
|
||||
// `cmd /c mklink /J <link> <target>`:三个参数分开传,别拼成一整条(拼成一串会被 cmd 当语法错),
|
||||
// 路径也必须**逐段 join**(`cmd` 把正斜杠当开关)。
|
||||
let output = std::process::Command::new("cmd")
|
||||
.args(["/c", "mklink", "/J"])
|
||||
.arg(link)
|
||||
@@ -281,12 +241,109 @@ pub fn try_junction(target: &Path, link: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次登记请求(测试用:固定 `created_at`,名字缺省 = 版本 id)。
|
||||
pub fn request(version_id: &str, target: AdoptTarget) -> AdoptRequest {
|
||||
AdoptRequest {
|
||||
version_id: version_id.to_string(),
|
||||
name: None,
|
||||
target,
|
||||
created_at: Some("2026-11-09T00:00:00.000Z".to_string()),
|
||||
/// 建目录链接:Windows 先试符号链接、再试联接;其它平台只有符号链接。
|
||||
pub fn try_link_dir(target: &Path, link: &Path) -> bool {
|
||||
if try_symlink_dir(target, link) {
|
||||
return true;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
try_junction(target, link)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 链接夹具建不出来时怎么走(**真值表**,复审 r1 的 R4 抓的就是这张表被写反)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LinkGate {
|
||||
/// 建成了:照常跑断言。
|
||||
Run,
|
||||
/// 没建成且**没要求强制**:打印一行记录后跳过(不静默)。
|
||||
Skip,
|
||||
/// 没建成且 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1`:**必红**,不许空跑。
|
||||
Fail,
|
||||
}
|
||||
|
||||
/// 纯判据:`(建成了吗, 是否强制)` → 怎么走。
|
||||
#[must_use]
|
||||
pub fn link_gate(created: bool, required: bool) -> LinkGate {
|
||||
match (created, required) {
|
||||
(true, _) => LinkGate::Run,
|
||||
(false, true) => LinkGate::Fail,
|
||||
(false, false) => LinkGate::Skip,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 时链接夹具**不许**空跑。
|
||||
#[must_use]
|
||||
pub fn links_required() -> bool {
|
||||
std::env::var("ULL_ADOPT_TEST_SYMLINK_REQUIRE")
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
== Ok("1")
|
||||
}
|
||||
|
||||
/// 链接夹具的守卫:返回 `true` = 继续跑断言;`false` = 已显式跳过(调用方 `return`)。
|
||||
///
|
||||
/// `required` 由环境变量决定([`links_required`]);判据本身是 [`link_gate`]。
|
||||
pub fn link_guard(created: bool, what: &str) -> bool {
|
||||
link_guard_with(created, links_required(), what)
|
||||
}
|
||||
|
||||
/// 同 [`link_guard`],但 `required` 显式传入(测试要能**执行**强制分支,而不靠改进程环境)。
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// `required` 且没建成链接/联接:**红**(这是复审 r1 的 R4:旧写法把方向写反了,
|
||||
/// 强制模式下建不出来反而直接「成功」返回)。
|
||||
pub fn link_guard_with(created: bool, required: bool, what: &str) -> bool {
|
||||
match link_gate(created, required) {
|
||||
LinkGate::Run => true,
|
||||
LinkGate::Skip => {
|
||||
eprintln!(
|
||||
"[link-guard] {what}:本机建不出符号链接/目录联接,已显式跳过这条用例; 设 ULL_ADOPT_TEST_SYMLINK_REQUIRE=1 可让它必跑(建不出来即红)"
|
||||
);
|
||||
false
|
||||
}
|
||||
LinkGate::Fail => panic!(
|
||||
"[link-guard] {what}:ULL_ADOPT_TEST_SYMLINK_REQUIRE=1,但本机建不出符号链接/目录联接 ——这条用例不许空跑,判红"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 真值表的回归:**强制模式建不出来必须致命**(R4 的反转就是这张表写错)。
|
||||
///
|
||||
/// 抓得住:把 `(false, true)` 判成 `Skip`(旧实现的写法)、或把 `(false, false)` 判成 `Fail`。
|
||||
/// 抓不住:真实建链接是否成功(那由各链接用例自己走 [`link_guard`])。
|
||||
#[test]
|
||||
fn link_gate_truth_table() {
|
||||
assert_eq!(link_gate(true, false), LinkGate::Run);
|
||||
assert_eq!(link_gate(true, true), LinkGate::Run);
|
||||
assert_eq!(link_gate(false, false), LinkGate::Skip);
|
||||
assert_eq!(
|
||||
link_gate(false, true),
|
||||
LinkGate::Fail,
|
||||
"强制模式下建不出链接必须红"
|
||||
);
|
||||
}
|
||||
|
||||
/// **执行**守卫的三个分支(不只测纯判据):Run 继续、Skip 返回 false、Fail panic。
|
||||
///
|
||||
/// 抓得住:把 `Fail` 分支写成 `return false`(`catch_unwind` 捕不到 panic,最后一条断言红);
|
||||
/// 把 `Skip` 写成 panic(第一条 `catch_unwind` 不再 ok)。
|
||||
/// 抓不住:真实建链接是否成功(各链接用例自己走 [`link_guard`]);也不依赖进程级环境变量,
|
||||
/// 因此可与并行用例共存。
|
||||
#[test]
|
||||
fn link_guard_executes_all_three_branches() {
|
||||
assert!(link_guard_with(true, false, "已建成"));
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| link_guard_with(false, false, "非强制模式 · 建不出")).is_ok(),
|
||||
"非强制模式不许 panic"
|
||||
);
|
||||
assert!(!link_guard_with(false, false, "非强制模式 · 建不出"));
|
||||
let forced = std::panic::catch_unwind(|| link_guard_with(false, true, "强制模式 · 建不出"));
|
||||
assert!(forced.is_err(), "强制模式下建不出来必须 panic(R4)");
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! 快照工具自身的回归:它必须抓得住「凭空多出一个空目录」(复审 r1 的 R5)。
|
||||
//!
|
||||
//! 早先的快照只记文件:`scan` 或 `--dry-run` 若在外部树里建了一个空目录(例如
|
||||
//! `<game>/versions/<v>/mods`),快照完全不变,于是「零写入」这条承诺可以是假绿。
|
||||
//! 这里用**同一份** `snapshot.rs` 证明:目录节点、链接节点都进快照,读盘失败不吞。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::fixture::{
|
||||
link_guard, snapshot, snapshot_opt, try_link_dir, try_symlink_dir, Node, TempRoot,
|
||||
};
|
||||
|
||||
/// 目录 / 文件 / 链接三类节点都记,且链接**不跟随**(目标内容不会挂到链接路径下)。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - 目录节点缺失(`dir` 断言红);
|
||||
/// - 跟随链接(`link/inside.txt` 会同时出现在链接路径下,`is_none` 断言红);
|
||||
/// - 链接被当成普通目录(`Node::Link` 断言红)。
|
||||
///
|
||||
/// 抓不住:链接目标本身的内容变化(不跟随就看不见——这正是本工具要的语义)。
|
||||
#[test]
|
||||
fn snapshot_records_dirs_files_and_links_without_following() {
|
||||
let root = TempRoot::new("snap-shapes");
|
||||
let real = root.path().join("real");
|
||||
std::fs::create_dir_all(&real).expect("建真实目录");
|
||||
std::fs::write(real.join("inside.txt"), b"x").expect("写文件");
|
||||
let link = root.path().join("link");
|
||||
if !link_guard(try_symlink_dir(&real, &link), "快照链接夹具") {
|
||||
// 链接建不出来时仍要验目录/文件两类(guard 已显式记录跳过原因)。
|
||||
let snap = snapshot(root.path());
|
||||
assert!(matches!(snap.get("real"), Some(Node::Dir { .. })));
|
||||
assert!(matches!(
|
||||
snap.get("real/inside.txt"),
|
||||
Some(Node::File { .. })
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let snap = snapshot(root.path());
|
||||
assert!(
|
||||
matches!(snap.get("."), Some(Node::Dir { .. })),
|
||||
"根自身也要记"
|
||||
);
|
||||
assert!(matches!(snap.get("real"), Some(Node::Dir { .. })));
|
||||
assert!(matches!(
|
||||
snap.get("real/inside.txt"),
|
||||
Some(Node::File { .. })
|
||||
));
|
||||
assert!(
|
||||
matches!(snap.get("link"), Some(Node::Link { .. })),
|
||||
"链接要记成 Link:{:?}",
|
||||
snap.get("link")
|
||||
);
|
||||
assert!(
|
||||
!snap.contains_key("link/inside.txt"),
|
||||
"不许跟随链接(否则根外/别处内容会被算进这棵树)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **新增一个空目录**必须让快照变化(R5 的核心判据)。
|
||||
///
|
||||
/// 抓得住:只记文件的旧快照(`before == after` 会让第一条断言红)。
|
||||
/// 抓不住:目录 mtime 的精度(这条只比路径集合与节点形状;mtime 由 `assert_unchanged` 另外守)。
|
||||
#[test]
|
||||
fn snapshot_catches_a_new_empty_directory() {
|
||||
let root = TempRoot::new("snap-newdir");
|
||||
let game = root.game_root().join("versions").join("1.20.1");
|
||||
std::fs::create_dir_all(&game).expect("建版本目录");
|
||||
let before = snapshot(&root.game_root());
|
||||
|
||||
// 「登记过程凭空建了一个空目录」——正是旧快照看不见的那种改动。
|
||||
let planted = game.join("mods");
|
||||
std::fs::create_dir_all(&planted).expect("建空目录");
|
||||
let after = snapshot(&root.game_root());
|
||||
|
||||
assert_ne!(before, after, "新增空目录必须改变快照");
|
||||
assert!(
|
||||
!before.contains_key("versions/1.20.1/mods"),
|
||||
"改动前不该有这个节点"
|
||||
);
|
||||
assert!(
|
||||
matches!(after.get("versions/1.20.1/mods"), Some(Node::Dir { .. })),
|
||||
"新增的空目录要作为目录节点出现:{:?}",
|
||||
after.get("versions/1.20.1/mods")
|
||||
);
|
||||
|
||||
// 反向:删掉之后路径集合回到原样(目录 mtime 会变,所以只比键集合)。
|
||||
std::fs::remove_dir(&planted).expect("删空目录");
|
||||
let again = snapshot(&root.game_root());
|
||||
assert_eq!(
|
||||
before.keys().collect::<Vec<_>>(),
|
||||
again.keys().collect::<Vec<_>>(),
|
||||
"删掉空目录后路径集合应回到原样"
|
||||
);
|
||||
}
|
||||
|
||||
/// 目录的 mtime 变化也要能看见(条目增删会改父目录 mtime)。
|
||||
///
|
||||
/// 抓得住:目录节点只记路径不记 mtime(那样 `Node::Dir{mtime_ns}` 相同,第二条断言红)。
|
||||
/// 抓不住:某些文件系统对 mtime 的粗粒度(本机 NTFS 是 100ns 级;真粗到同一 tick 时这条会软过)。
|
||||
#[test]
|
||||
fn snapshot_notices_a_directory_mtime_change() {
|
||||
let root = TempRoot::new("snap-dirmtime");
|
||||
let dir = root.path().join("watched");
|
||||
std::fs::create_dir_all(&dir).expect("建目录");
|
||||
let before = snapshot(root.path());
|
||||
std::fs::write(dir.join("new.txt"), b"x").expect("写文件");
|
||||
let after = snapshot(root.path());
|
||||
let before_node = before.get("watched").expect("有该目录节点");
|
||||
let after_node = after.get("watched").expect("有该目录节点");
|
||||
assert_ne!(
|
||||
before_node, after_node,
|
||||
"目录里加了文件,目录节点(含 mtime)必须变"
|
||||
);
|
||||
}
|
||||
|
||||
/// 目标根本不存在 ⇒ `snapshot_opt` 给 `None`(与「空树」区分开)。
|
||||
///
|
||||
/// 抓得住:把「不存在」当成空快照(那样「dry-run 建了 .ULL」就看不出来)。
|
||||
/// 抓不住:存在但权限不足(那一支直接 panic,见模块文档)。
|
||||
#[test]
|
||||
fn a_missing_target_is_none_not_an_empty_snapshot() {
|
||||
let root = TempRoot::new("snap-missing");
|
||||
let absent: &Path = &root.path().join("nope");
|
||||
assert!(snapshot_opt(absent).is_none());
|
||||
std::fs::create_dir_all(absent).expect("建空目录");
|
||||
assert!(snapshot_opt(absent).is_some(), "空目录也算存在");
|
||||
}
|
||||
|
||||
/// 链接建不出来时 `try_link_dir` 会退回联接(Windows);这条只验它不 panic。
|
||||
#[test]
|
||||
fn link_fixtures_are_total() {
|
||||
let root = TempRoot::new("snap-link-total");
|
||||
let target = root.path().join("target");
|
||||
std::fs::create_dir_all(&target).expect("建目标");
|
||||
let link = root.path().join("link");
|
||||
let created = try_link_dir(&target, &link);
|
||||
if created {
|
||||
assert!(link.is_dir());
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
//! M221 core 侧集成测试入口:从**别人的** `.minecraft` 只读登记版本(adopt)。
|
||||
//!
|
||||
//! 分工:
|
||||
//! - `fixture`:临时真实磁盘目录 + 一份「别的启动器铺好的」`.minecraft` + 全量快照工具;
|
||||
//! - `fixture`:临时真实磁盘目录 + 一份「别的启动器铺好的」`.minecraft`;
|
||||
//! - `snapshot`:**全节点**快照(目录/文件/链接 + mtime),core 与 CLI 测试共用这一份;
|
||||
//! - `scan_cases`:`scan` / `inspect` 的判据(正常 / 坏 JSON / 缺父 / 成环 / 缺清单 / 越界符号链接);
|
||||
//! - `plan_cases`:还没登记时的**只读**判据与拒绝理由(dry-run 零写盘 / 缺父 / 不存在 / 非法目标);
|
||||
//! - `collision_cases`:缺省运行目录**不许被两个实例共用**(slug / 大小写 / 已被占 / 认领但未创建);
|
||||
//! - `register_cases`:`adopt` 的落点与写盘面(缺省独立目录 / shared / isolated / custom /
|
||||
//! 重名 / 重复 / 非空目录 / 数据区联接 / 外部文件哈希与路径集合不变)。
|
||||
//! 重名 / 重复 / 非空目录 / 数据区联接 / **slug 与大小写碰撞不许共用运行目录** / 外部树全节点不变);
|
||||
//! - `surface_cases`:配置写面被链接走出去必须拒绝(R2)、`versions` 入口断链必须报错(R3);
|
||||
//! - `fixture_cases`:快照工具自身的回归(它得抓得住「凭空多一个空目录」,R5)。
|
||||
//!
|
||||
//! 这些用例**不启动真实 Minecraft**:验的是「登记这一步做了什么、没做什么」,启动链路由
|
||||
//! `tests/game/*` 与 CLI 的 `launch --dry-run` 各自覆盖。
|
||||
|
||||
mod collision_cases;
|
||||
mod fixture;
|
||||
mod fixture_cases;
|
||||
mod plan_cases;
|
||||
mod register_cases;
|
||||
mod scan_cases;
|
||||
mod snapshot;
|
||||
mod surface_cases;
|
||||
@@ -7,7 +7,9 @@ use std::path::PathBuf;
|
||||
use unlucky_core::game::adopt::{self, AdoptError, AdoptTarget};
|
||||
use unlucky_core::game::install::instance::Isolation;
|
||||
|
||||
use crate::fixture::{assert_unchanged, foreign_minecraft, request, snapshot, TempRoot};
|
||||
use crate::fixture::{
|
||||
assert_unchanged, foreign_minecraft, request, snapshot, snapshot_opt, TempRoot,
|
||||
};
|
||||
|
||||
/// 缺省档位本身就是「数据区独立目录」,而且**不是** `isolated`(判据的单一真相源)。
|
||||
///
|
||||
@@ -63,7 +65,11 @@ fn plan_writes_nothing_at_all() {
|
||||
|
||||
assert!(plan.candidate.adoptable());
|
||||
assert!(!plan.run_dir.exists(), "plan 不该建目录");
|
||||
assert!(!root.path().join(".ULL").exists(), "plan 不该建配置目录");
|
||||
// `.ULL` 连**空目录**都不许出现(存在性用 `snapshot_opt`,与「空树」区分开)。
|
||||
assert!(
|
||||
snapshot_opt(&root.path().join(".ULL")).is_none(),
|
||||
"plan 不该建配置目录(连空的也不行)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 不可登记的版本(缺父)⇒ 一条都不登记,问题清单原样带出。
|
||||
|
||||
@@ -273,12 +273,10 @@ fn a_junction_inside_the_data_area_does_not_get_written_through() {
|
||||
// 逐段 join:`mklink` 把正斜杠当开关,拼成一串带 `/` 的路径会被判「无效参数」。
|
||||
let data_instances = root.path().join(".ULL").join("data").join("instances");
|
||||
std::fs::create_dir_all(data_instances.parent().expect("父目录")).expect("建 data 目录");
|
||||
if !crate::fixture::try_junction(&outside, &data_instances) {
|
||||
assert_eq!(
|
||||
std::env::var("ULL_ADOPT_TEST_SYMLINK_REQUIRE").as_deref(),
|
||||
Ok("1"),
|
||||
"[symlink-guard] 本机建不出目录联接(mklink /J 失败),这条用例没有跑"
|
||||
);
|
||||
if !crate::fixture::link_guard(
|
||||
crate::fixture::try_junction(&outside, &data_instances),
|
||||
"数据区目录联接",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
//! 每条断言下面都标了「抓得住 / 抓不住」:抓得住的是变异实现会变红的具体结论,
|
||||
//! 抓不住的是只能靠人读的东西(措辞、是否还有别的静默吞掉路径)。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::game::adopt::{self, AdoptCandidate};
|
||||
|
||||
use crate::fixture::{assert_unchanged, foreign_minecraft, snapshot, TempRoot};
|
||||
@@ -208,8 +206,8 @@ fn version_ids_that_cannot_be_paths_are_rejected() {
|
||||
///
|
||||
/// 抓得住:`ensure_inside` 不比较解析后的真实路径(那样 `1.20.1-link` 会变成「可登记」)。
|
||||
/// 抓不住 / 已知局限:
|
||||
/// - 本机不允许建符号链接(Windows 未开开发者模式、或非管理员)时这条会打印 `[symlink-guard]`
|
||||
/// 并**跳过**——显式记录而不是静静通过;设 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 可让它必跑(建不出来即红);
|
||||
/// - 本机不允许建符号链接(Windows 未开开发者模式、或非管理员)时这条会打印 `[link-guard]`
|
||||
/// 并**跳过**——显式记录而不是静静通过;设 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 则建不出来**必红**;
|
||||
/// - 目录联接(junction)由下面单独那条用例覆盖(不需要管理员权限,是更常见的形态)。
|
||||
#[test]
|
||||
fn a_symlinked_version_pointing_outside_the_game_root_is_rejected() {
|
||||
@@ -223,14 +221,11 @@ fn a_symlinked_version_pointing_outside_the_game_root_is_rejected() {
|
||||
)
|
||||
.expect("写外部清单");
|
||||
let link = root.game_root().join("versions/1.20.1-link");
|
||||
if !try_symlink_dir(&outside.join("1.20.1"), &link) {
|
||||
// 显式记录 + 环境变量可强制:不允许「什么都没跑还说绿」。
|
||||
assert_eq!(
|
||||
std::env::var("ULL_ADOPT_TEST_SYMLINK_REQUIRE").as_deref(),
|
||||
Ok("1"),
|
||||
"[symlink-guard] 本机不允许建符号链接,这条用例没有跑:\
|
||||
要强制它必跑设 ULL_ADOPT_TEST_SYMLINK_REQUIRE=1(建不出来即红)"
|
||||
);
|
||||
// 建不出来时:非强制模式打印一行并跳过;`ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 必红(R4)。
|
||||
if !crate::fixture::link_guard(
|
||||
crate::fixture::try_symlink_dir(&outside.join("1.20.1"), &link),
|
||||
"根外符号链接",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,7 +245,7 @@ fn a_symlinked_version_pointing_outside_the_game_root_is_rejected() {
|
||||
/// 游戏根内的符号链接(指向同一棵树里的真实版本目录)**不**拒绝:那是合法的省盘做法。
|
||||
///
|
||||
/// 抓得住:`check_symlink` 一刀切拒绝所有符号链接(这条会红)。
|
||||
/// 抓不住:本机建不出链接时同上跳过(打印 `[symlink-guard]`)。
|
||||
/// 抓不住:本机建不出链接时同上跳过(打印 `[link-guard]`,强制模式下必红)。
|
||||
#[test]
|
||||
fn a_symlink_inside_the_game_root_is_allowed() {
|
||||
let root = TempRoot::new("symlink-in");
|
||||
@@ -264,12 +259,10 @@ fn a_symlink_inside_the_game_root_is_allowed() {
|
||||
)
|
||||
.expect("写清单");
|
||||
let link = root.game_root().join("versions/1.20.1-thin");
|
||||
if !try_symlink_dir(&real, &link) {
|
||||
assert_eq!(
|
||||
std::env::var("ULL_ADOPT_TEST_SYMLINK_REQUIRE").as_deref(),
|
||||
Ok("1"),
|
||||
"[symlink-guard] 本机不允许建符号链接,这条用例没有跑"
|
||||
);
|
||||
if !crate::fixture::link_guard(
|
||||
crate::fixture::try_symlink_dir(&real, &link),
|
||||
"根内符号链接",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let candidate = candidate_of(&root, "1.20.1-thin");
|
||||
@@ -280,15 +273,6 @@ fn a_symlink_inside_the_game_root_is_allowed() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 建目录符号链接(`true` = 建成;`false` = 本机没权限/不支持)。
|
||||
fn try_symlink_dir(target: &Path, link: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
let created = std::os::unix::fs::symlink(target, link).is_ok();
|
||||
#[cfg(windows)]
|
||||
let created = std::os::windows::fs::symlink_dir(target, link).is_ok();
|
||||
created
|
||||
}
|
||||
|
||||
/// `versions/` 不存在 = 空结果(新数据根不是错误),但**不存在的游戏根不会被建出来**。
|
||||
///
|
||||
/// 抓得住:`scan` 顺手 `create_dir_all(versions)`(下面的断言会红),或把「不存在」当错误。
|
||||
@@ -315,7 +299,8 @@ fn a_missing_versions_dir_is_empty_not_an_error() {
|
||||
/// - 判据退回 `is_symlink`(除 Windows junction 外全都看不到,此时候选会变成「可登记」,第一条断言红);
|
||||
/// - 判据排到**解析清单之后**:根外那份清单是合法 JSON 且 id 与目录名一致,一旦走到解析,
|
||||
/// `manifest_id` 就会被填上(第二条断言红)——所以它至少能证明「没解析过根外清单」;
|
||||
/// - 库里没有 `mklink` 时打印 `[symlink-guard]` 并跳过(设 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 必跑)。
|
||||
/// - 建不出链接时走 `fixture::link_guard`:非强制模式打印 `[link-guard]` 并跳过,
|
||||
/// 强制模式(`ULL_ADOPT_TEST_SYMLINK_REQUIRE=1`)**必红**(真值表见 `fixture::link_gate_truth_table`)。
|
||||
///
|
||||
/// 抓不住(证据边界,别把它读大):**只 stat/只 open 不解析**的读取这里看不出来
|
||||
/// (`manifest_id` 为 `None` 只说明没解析过,不说明没碰过文件);「边界检查排在
|
||||
@@ -335,12 +320,10 @@ fn a_junction_pointing_outside_is_rejected_without_reading_it() {
|
||||
)
|
||||
.expect("写外部清单");
|
||||
let link = root.game_root().join("versions").join("1.20.1-junction");
|
||||
if !crate::fixture::try_junction(&outside, &link) {
|
||||
assert_eq!(
|
||||
std::env::var("ULL_ADOPT_TEST_SYMLINK_REQUIRE").as_deref(),
|
||||
Ok("1"),
|
||||
"[symlink-guard] 本机建不出目录联接(mklink /J 失败),这条用例没有跑"
|
||||
);
|
||||
if !crate::fixture::link_guard(
|
||||
crate::fixture::try_junction(&outside, &link),
|
||||
"根外目录联接",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! 目录树**全节点**快照:目录 / 文件 / 链接各记自己的类型与适用 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! 写盘面与入口可用性:**配置目录被链出去**必须拒绝(R2)、**versions 入口断链**必须具名报错(R3)。
|
||||
//!
|
||||
//! 两类都属于「本 mission 承诺的东西被链接绕过」:前者是「只写自己的配置」被
|
||||
//! `config/instances` 的目录联接打穿(会替别人的启动器在 `.minecraft/config` 里造 UUID 文件),
|
||||
//! 后者是「坏东西不能当空列表」被 `read_dir` 的 NotFound 打穿(断链 junction 与「真没有」同态)。
|
||||
//!
|
||||
//! 两条都用**真链接夹具**;建不出链接时走 `fixture::link_guard`(强制模式下必红,见 R4)。
|
||||
|
||||
use unlucky_core::game::adopt::{self, AdoptTarget};
|
||||
|
||||
use crate::fixture::{
|
||||
assert_unchanged, foreign_minecraft, link_guard, request, snapshot, try_link_dir, TempRoot,
|
||||
};
|
||||
|
||||
/// `config/instances` 被目录联接指到游戏根的 `config/`:adopt 必须先拒绝,且游戏根零写入。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - 只核运行目录、不核配置写面(那样这次登记会 exit0,并在 `.minecraft/config/<uuid>.json`
|
||||
/// 多出一个文件——`before/after` 全节点快照与「外部 config 目录为空」两条都会红);
|
||||
/// - `plan`(dry-run)也放行(第四条断言红:预检说「可以」,真写才失败);
|
||||
/// - 拒绝时先写了目录/配置(快照与目录列举都会红)。
|
||||
///
|
||||
/// 抓不住:检查与写入之间的 TOCTOU(本模块不是并发/对抗入口)。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_junction_on_the_instances_dir_must_not_write_into_the_game_root() {
|
||||
use crate::fixture::try_junction;
|
||||
|
||||
let root = TempRoot::new("cfg-instances-junction");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let game_config = root.game_root().join("config");
|
||||
std::fs::create_dir_all(&game_config).expect("建别人的 config 目录");
|
||||
let instances_dir = root.path().join(".ULL").join("config").join("instances");
|
||||
std::fs::create_dir_all(instances_dir.parent().expect("config 目录")).expect("建 .ULL/config");
|
||||
if !link_guard(
|
||||
try_junction(&game_config, &instances_dir),
|
||||
"config/instances 目录联接",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let before = snapshot(&root.game_root());
|
||||
// 别人的 `config/` 里本来就有文件(`example.toml` 是玩家配置):判据是**没有新增条目**。
|
||||
let config_before = listing(&game_config);
|
||||
|
||||
let plan_error = adopt::plan(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("配置写面被链走时,dry-run 也必须报错");
|
||||
assert_eq!(plan_error.code(), "adopt_config_surface_unsafe");
|
||||
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("配置写面被链走时必须拒绝登记");
|
||||
assert_eq!(error.code(), "adopt_config_surface_unsafe");
|
||||
|
||||
let after = snapshot(&root.game_root());
|
||||
assert_unchanged(&before, &after, "拒绝之后(游戏根零写入)");
|
||||
assert_eq!(
|
||||
config_before,
|
||||
listing(&game_config),
|
||||
"绝不许在别人的 .minecraft/config 里新增文件(尤其 UUID 配置)"
|
||||
);
|
||||
assert!(
|
||||
!listing(&game_config)
|
||||
.iter()
|
||||
.any(|name| name.ends_with(".json") && name.len() == 41),
|
||||
"不该出现 UUID 形状的 json:{:?}",
|
||||
listing(&game_config)
|
||||
);
|
||||
}
|
||||
|
||||
/// `config/` 整层被链到游戏根:同样拒绝(判定粒度到**每一个**写盘目标,不只看 `instances`)。
|
||||
///
|
||||
/// 抓得住:只检查 `config/instances` 而不检查 `config/` 与 `profiles.json`(那样这条会 exit0)。
|
||||
/// 抓不住:跨卷/跨盘的整体迁移(`<.ULL>` 自己是联接)——那是允许的,见 `run_dir` 模块文档。
|
||||
#[test]
|
||||
fn a_linked_config_dir_must_not_write_into_the_game_root() {
|
||||
let root = TempRoot::new("cfg-dir-link");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let game_config = root.game_root().join("config");
|
||||
std::fs::create_dir_all(&game_config).expect("建别人的 config 目录");
|
||||
let ull = root.path().join(".ULL");
|
||||
std::fs::create_dir_all(&ull).expect("建 .ULL");
|
||||
let config_dir = ull.join("config");
|
||||
if !link_guard(try_link_dir(&game_config, &config_dir), "config 目录链接") {
|
||||
return;
|
||||
}
|
||||
let before = snapshot(&root.game_root());
|
||||
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("config/ 被链走时必须拒绝登记");
|
||||
assert_eq!(error.code(), "adopt_config_surface_unsafe");
|
||||
|
||||
let after = snapshot(&root.game_root());
|
||||
assert_unchanged(&before, &after, "拒绝之后(游戏根零写入)");
|
||||
}
|
||||
|
||||
/// `versions/` 入口**存在但断链**(目标被移走)⇒ 具名报错,而不是「这个数据根还没有版本」。
|
||||
///
|
||||
/// 抓得住:`read_dir` 的 `NotFound` 一律当空列表(旧实现;Windows 断链 junction 正是这个形状)
|
||||
/// ——第一条断言会红成「成功空列表」。
|
||||
/// 抓不住:`versions/` 是普通文件的情形(走 `GameError::io`,错误码不同,靠人读)。
|
||||
#[test]
|
||||
fn a_dangling_versions_entry_is_an_error_not_an_empty_scan() {
|
||||
let root = TempRoot::new("dangling-versions");
|
||||
std::fs::create_dir_all(root.game_root()).expect("建游戏根");
|
||||
let moved = root.path().join("moved-versions");
|
||||
std::fs::create_dir_all(moved.join("1.20.1")).expect("建将被移走的 versions");
|
||||
let versions = root.game_root().join("versions");
|
||||
if !link_guard(try_link_dir(&moved, &versions), "versions 入口链接") {
|
||||
return;
|
||||
}
|
||||
// 目标被移走 ⇒ 入口成为断链;lstat 仍然看得见它。
|
||||
std::fs::remove_dir_all(&moved).expect("移走目标");
|
||||
assert!(
|
||||
std::fs::symlink_metadata(&versions).is_ok(),
|
||||
"夹具有问题:断链入口应当仍能被 lstat 看到"
|
||||
);
|
||||
|
||||
let error = adopt::scan(&root.game(), &root.launcher())
|
||||
.expect_err("断链的 versions 入口必须报错,不能当空列表");
|
||||
assert_eq!(error.code(), "adopt_versions_unreadable", "{error}");
|
||||
}
|
||||
|
||||
/// `versions` 真不存在 ⇒ 仍然是空结果(别把「新数据根」误报成故障)。
|
||||
///
|
||||
/// 抓得住:为了修 R3 把所有 NotFound 都当错误(这条会红)。
|
||||
/// 抓不住:真正不可读(EACCES)那一支——Windows 上造不可读目录不可靠。
|
||||
#[test]
|
||||
fn a_truly_missing_versions_dir_stays_empty() {
|
||||
let root = TempRoot::new("no-versions");
|
||||
std::fs::create_dir_all(root.game_root()).expect("建空游戏根");
|
||||
let scan = adopt::scan(&root.game(), &root.launcher()).expect("真不存在 ⇒ 空结果");
|
||||
assert!(scan.candidates.is_empty());
|
||||
}
|
||||
|
||||
/// 目录里直接子项的名字(排序后,用于「有没有新增」的判据)。
|
||||
fn listing(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir)
|
||||
.expect("读目录")
|
||||
.filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
Reference in new issue
Block a user