fix(game): 复审 r2 三项——Win32 尾点/尾空格别名 + 写面与游戏树重叠 + 更高祖先断链
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 重解析点未覆盖。
This commit is contained in:
1 parent
8110cde3f5
commit
347a3cc90d
12 files changed
+674
-149
No files matched your search
@@ -48,7 +48,7 @@ fn a_junction_on_the_instances_dir_must_not_write_into_the_game_root() {
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("配置写面被链走时,dry-run 也必须报错");
|
||||
assert_eq!(plan_error.code(), "adopt_config_surface_unsafe");
|
||||
assert_eq!(plan_error.code(), "adopt_write_surface_in_game_tree");
|
||||
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
@@ -56,7 +56,7 @@ fn a_junction_on_the_instances_dir_must_not_write_into_the_game_root() {
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("配置写面被链走时必须拒绝登记");
|
||||
assert_eq!(error.code(), "adopt_config_surface_unsafe");
|
||||
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
|
||||
|
||||
let after = snapshot(&root.game_root());
|
||||
assert_unchanged(&before, &after, "拒绝之后(游戏根零写入)");
|
||||
@@ -98,7 +98,7 @@ fn a_linked_config_dir_must_not_write_into_the_game_root() {
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("config/ 被链走时必须拒绝登记");
|
||||
assert_eq!(error.code(), "adopt_config_surface_unsafe");
|
||||
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
|
||||
|
||||
let after = snapshot(&root.game_root());
|
||||
assert_unchanged(&before, &after, "拒绝之后(游戏根零写入)");
|
||||
@@ -152,3 +152,169 @@ fn listing(dir: &std::path::Path) -> Vec<String> {
|
||||
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}");
|
||||
}
|
||||
Reference in new issue
Block a user