r1 五项里 R4/R5 与 slug/大小写等直接案例已由 r2 复审独立通过;这三条是它补证出的**剩余边界**, 只做限定修复,不推翻重写: r2.1(未存在认领路径的 Win32 尾点/尾空格别名):比较键新增 `fspaths::comparison_real()`—— 已存在前缀用 `canonicalize`,**尚不存在的尾段按平台语义归一**:Windows 普通路径折叠尾 `.`/空格 (`Foo.` 与 `Foo ` 都是 `Foo`,Win32 建目录时就这么做),**verbatim 路径(`\?\…`)不折叠** (那是另一个物理目录,合法)。原先 `dir_key` 只统一分隔符与大小写,于是一个尚未创建的 `custom` 认领 `Foo.` 与缺省派生 `Foo` 键不相等却被判定「不撞」,两个实例共用一个运行目录。 r2.2(`data`/`.ULL` 整体映射到受保护游戏树时仍静默外写):新增 `fspaths::ensure_surfaces_clear_of_game_tree()`——默认写盘面(`.ULL`、`config/`、 `config/instances/`、`profiles.json`、`data/`,以及缺省档的运行目录)**解析后不得落进游戏根**; `plan`(dry-run 也报)与 `adopt`(写盘前)各核一次,重叠即 `adopt_write_surface_in_game_tree` 具名拒绝并提示把数据目录挪到游戏树之外。整体迁移到游戏树**之外**照旧允许(判据是落点不是链接)。 配置写面判据同时收窄职责:`ensure_config_write_surface` 只管「在 `.ULL` 内」, 不再带「`.ULL` 在游戏根里」的豁免特例——重叠由上面那条统一负责。 r2.3(游戏根更高祖先断链仍当空列表):`scan` 的 `NotFound` 分支改为沿祖先找**最近的已存在入口**, 它能 `lstat` 到但 `canonicalize` 不出来(断链的符号链接/目录联接、目标被搬走)⇒ `adopt_versions_unreadable`;一路到盘根都不存在才是「真正的新数据根」,空结果仍允许。 原先只查 `versions` 与游戏根两层,迁移联接在更高层断开时会误报空库。 新错误码:`adopt_write_surface_in_game_tree`(CLI hint 与 help 拒绝清单同步)。 验证(持久日志在 `target/m221-r2/`,git 忽略、不入库;上一轮 `target/m221-r1/` 证据未覆盖): - `cargo fmt --all --check` 0;`cargo check -p unlucky-core -p unlucky-cli --all-targets` 0; - `cargo test -p unlucky-core --test adopt` **43 passed / 0 failed**(另跑一次 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 同样 43 passed); - `cargo test -p unlucky-cli --test adopt_test` **13 passed / 0 failed**; - `cargo clippy -p unlucky-core -p unlucky-cli --all-targets -- -D warnings` 0; - 额外:`cargo test -p unlucky-core --lib` **778 passed**、`cargo test -p unlucky-cli` 全部目标通过; - 三次**实现-only 变异**(不折叠 Win32 尾点尾空格 / 不核游戏树重叠 / NotFound 直接当空) 全部以**断言红**收场(1 / 4 / 2 条失败,非编译错误、非环境错误),每次还原后与基线逐文件一致, 收尾再跑 core+cli 双绿。 界限:只覆盖 core+cli 的定向目标(不等于 workspace 全量);没有启动真实 Minecraft;GUI 未接; 判据是检查那一刻的快照(TOCTOU 看不到);Win32 8.3 短名与非 junction 重解析点未覆盖。
321 lines
14 KiB
Rust
321 lines
14 KiB
Rust
//! 写盘面与入口可用性:**配置目录被链出去**必须拒绝(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_write_surface_in_game_tree");
|
||
|
||
let error = adopt::adopt(
|
||
&root.game(),
|
||
&root.launcher(),
|
||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||
)
|
||
.expect_err("配置写面被链走时必须拒绝登记");
|
||
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
|
||
|
||
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_write_surface_in_game_tree");
|
||
|
||
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
|
||
}
|
||
|
||
/// `.ULL/data` 被预先联接到**游戏树**上 ⇒ 默认登记必须拒绝(r2 R2.2),而不是写完再说「隔离」。
|
||
///
|
||
/// 复审 r2 的实跑:`data -> .minecraft` 时 dry-run 与 adopt 都 exit0,外部多出
|
||
/// `instances/<slug>` 且游戏根 mtime 变化,输出仍称「与别人的 .minecraft 隔离」。
|
||
///
|
||
/// 抓得住:写面重叠不核(那样这次登记会成功,游戏树快照与「外部零新增」断言都会红);
|
||
/// 只核配置不核 data(`data` 面上的缺省运行目录仍会落进游戏树)。
|
||
///
|
||
/// 抓不住:检查与写入之间的 TOCTOU;也不把「后续游戏自己写盘」算作本判据的证据。
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn data_linked_into_the_game_tree_is_refused_before_any_write() {
|
||
use crate::fixture::try_junction;
|
||
|
||
let root = TempRoot::new("data-to-game");
|
||
foreign_minecraft(&root.game_root());
|
||
let data = root.path().join(".ULL").join("data");
|
||
std::fs::create_dir_all(data.parent().expect(".ULL")).expect("建 .ULL");
|
||
if !link_guard(try_junction(&root.game_root(), &data), "data -> 游戏树联接") {
|
||
return;
|
||
}
|
||
let before = snapshot(&root.game_root());
|
||
|
||
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_write_surface_in_game_tree");
|
||
|
||
let error = adopt::adopt(
|
||
&root.game(),
|
||
&root.launcher(),
|
||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||
)
|
||
.expect_err("默认登记必须拒绝与游戏树重叠的 data 写面");
|
||
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
|
||
|
||
assert_unchanged(
|
||
&before,
|
||
&snapshot(&root.game_root()),
|
||
"拒绝之后(游戏树零写入)",
|
||
);
|
||
assert!(
|
||
!root.game_root().join("instances").exists(),
|
||
"绝不许在别人的 .minecraft 里建 instances 目录"
|
||
);
|
||
}
|
||
|
||
/// `.ULL` 整棵树被预先联接到游戏树 ⇒ 同样拒绝(配置 + data 一起落进游戏树)。
|
||
///
|
||
/// 抓得住:只核 `data` 或只核 `config/instances`(那样 `profiles.json` 与 `data/` 仍会写穿)。
|
||
///
|
||
/// 抓不住:`<.ULL>` 自己是链接但指向**游戏树之外**的情形——那是合法迁移,见下一条正向对照。
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn home_linked_into_the_game_tree_is_refused_before_any_write() {
|
||
use crate::fixture::try_junction;
|
||
|
||
let root = TempRoot::new("home-to-game");
|
||
foreign_minecraft(&root.game_root());
|
||
let home = root.path().join(".ULL");
|
||
if !link_guard(try_junction(&root.game_root(), &home), ".ULL -> 游戏树联接") {
|
||
return;
|
||
}
|
||
let before = snapshot(&root.game_root());
|
||
|
||
let error = adopt::adopt(
|
||
&root.game(),
|
||
&root.launcher(),
|
||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||
)
|
||
.expect_err("默认登记必须拒绝与游戏树重叠的 .ULL 写面");
|
||
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
|
||
|
||
assert_unchanged(
|
||
&before,
|
||
&snapshot(&root.game_root()),
|
||
"拒绝之后(游戏树零写入)",
|
||
);
|
||
}
|
||
|
||
/// **正向对照**:把 `data` 整体迁到游戏树**之外**仍然允许(迁移是合法的,重叠才拒)。
|
||
///
|
||
/// 抓得住:把「与游戏树重叠」写成「任何链接都拒」(这条正向用例会红)。
|
||
/// 抓不住:迁移后的数据目录内容(只断言登记成功且游戏树不变)。
|
||
#[test]
|
||
fn data_migrated_outside_the_game_tree_still_registers() {
|
||
let root = TempRoot::new("data-outside");
|
||
foreign_minecraft(&root.game_root());
|
||
let external = root.path().join("external-data");
|
||
std::fs::create_dir_all(&external).expect("建外部数据目录");
|
||
let data = root.path().join(".ULL").join("data");
|
||
std::fs::create_dir_all(data.parent().expect(".ULL")).expect("建 .ULL");
|
||
if !link_guard(try_link_dir(&external, &data), "data -> 游戏树之外的联接") {
|
||
return;
|
||
}
|
||
let before = snapshot(&root.game_root());
|
||
|
||
let outcome = adopt::adopt(
|
||
&root.game(),
|
||
&root.launcher(),
|
||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||
)
|
||
.expect("迁到游戏树之外的 data 应允许");
|
||
// 对外返回的是**逻辑**路径(`<.ULL>/data/instances/<slug>`),真实位置才是迁移后的目录。
|
||
let real = outcome.run_dir.canonicalize().expect("运行目录真实路径");
|
||
let external_real = external.canonicalize().expect("外部数据目录真实路径");
|
||
assert!(
|
||
real.starts_with(&external_real),
|
||
"运行目录的真实位置应落在迁移后的数据目录里:{}",
|
||
real.display()
|
||
);
|
||
assert_unchanged(
|
||
&before,
|
||
&snapshot(&root.game_root()),
|
||
"登记之后(游戏树零写入)",
|
||
);
|
||
}
|
||
|
||
/// **更高祖先断链**(r2 R2.3):完整版本树挂在迁移联接下面,联接目标被搬走后 scan 不许报空。
|
||
///
|
||
/// 复审 r2 的对照:`--data-dir link/nested` 断开前 total=1;把 `link` 的目标改名后,
|
||
/// `versions` 与游戏根本身都 NotFound,旧实现返回 `ok:true / total:0`。
|
||
///
|
||
/// 抓得住:只在 `versions` 与游戏根两层 lstat(更高层断链会漏,第二条断言红);
|
||
/// 或反过来把「真正的新数据根」也判成错误(断开**之前**那条 total==1 的断言与
|
||
/// `a_truly_missing_versions_dir_stays_empty` 会红)。
|
||
#[test]
|
||
fn a_dangling_ancestor_above_the_game_root_is_not_an_empty_scan() {
|
||
let root = TempRoot::new("ancestor-dangling");
|
||
let real = root.path().join("target");
|
||
let game = real.join("nested").join(".minecraft");
|
||
std::fs::create_dir_all(game.join("versions").join("1.20.1")).expect("建完整版本树");
|
||
std::fs::write(
|
||
game.join("versions/1.20.1/1.20.1.json"),
|
||
br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#,
|
||
)
|
||
.expect("写清单");
|
||
let link = root.path().join("game-parent-link");
|
||
if !link_guard(try_link_dir(&real, &link), "游戏根上层联接") {
|
||
return;
|
||
}
|
||
let data_root = root.path().join("data");
|
||
std::fs::create_dir_all(&data_root).expect("建数据根");
|
||
let minecraft = link.join("nested").join(".minecraft");
|
||
let game_layout = unlucky_core::game::GameLayout::new(&minecraft);
|
||
let launcher = unlucky_core::platform::layout::LauncherLayout::new(data_root.join(".ULL"));
|
||
|
||
// 断开之前:完整版本树看得见。
|
||
let scan = adopt::scan(&game_layout, &launcher).expect("联接完好的树应可扫");
|
||
assert_eq!(scan.candidates.len(), 1, "完整版本树应扫到 1 个候选");
|
||
|
||
// 断开:把联接目标改名,入口还在但解析不出来(versions 与游戏根本身都 NotFound)。
|
||
std::fs::rename(&real, root.path().join("moved-target")).expect("移走联接目标");
|
||
assert!(
|
||
std::fs::symlink_metadata(&link).is_ok(),
|
||
"夹具有问题:断链入口应仍能被 lstat 看到"
|
||
);
|
||
|
||
let error =
|
||
adopt::scan(&game_layout, &launcher).expect_err("祖先断链必须具名报错,不能当空结果");
|
||
assert_eq!(error.code(), "adopt_versions_unreadable", "{error}");
|
||
}
|