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:
AskaEth committed 2026-11-09 23:56:02 +08:00
1 parent 8110cde3f5
commit 347a3cc90d
12 files changed
+674 -149

No files matched your search

@@ -142,3 +142,80 @@ fn a_claim_on_a_not_yet_existing_dir_still_collides() {
b.notes
);
}
/// **Win32 尾点/尾空格别名**:`custom` 认领 `Foo.`(目录还没建)与缺省派生 `Foo` 是**同一个物理目录**。
///
/// 复审 r2 的 R2.1:普通路径的尾点/尾空格会被 Win32 折叠,旧实现把尚不存在的尾段原样拼进比较键,
/// 于是第二次默认登记仍拿到 `Foo`(两次都 exit0、两个 UUID、同一物理目录)。
///
/// 抓得住:`dir_key` 只做分隔符/大小写归一(`Foo.` 与 `Foo` 键不等,第二条断言红)。
///
/// 抓不住:verbatim 路径(`\\?\…`)——那种写法**不**折叠,是另一个物理目录,见下一条用例。
#[cfg(windows)]
#[test]
fn a_trailing_dot_claim_still_collides_with_the_derived_dir() {
for suffix in [".", " "] {
let root = TempRoot::new("win32-alias");
foreign_minecraft(&root.game_root());
let claimed = pack_dir::instance_dir(&root.launcher(), &format!("Foo{suffix}"))
.with_file_name(format!("Foo{suffix}"));
assert!(!claimed.exists(), "夹具前提:认领目录还不存在");
let mut first = request("1.20.1", AdoptTarget::Custom(claimed.clone()));
first.name = Some("First".to_string());
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("custom 登记成功");
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("第二次默认登记应成功(顺延到 -2,而不是复用同一目录)");
assert_ne!(
b.run_dir, a.run_dir,
"尾点/尾空格别名不许让两个实例共用一个运行目录(suffix={suffix:?})"
);
assert!(
b.run_dir
.file_name()
.map(|name| name.to_string_lossy().ends_with("-2"))
.unwrap_or(false),
"应顺延成 -2:{}",
b.run_dir.display()
);
assert!(
!a.run_dir.exists(),
"custom 认领的目录不该被创建:{}",
a.run_dir.display()
);
}
}
/// 正向:**verbatim** 路径(`\\?\…`)不折叠 —— 它是另一个物理目录,必须允许并存。
///
/// 抓得住:归一化时盲目 trim(verbatim 的 `Foo.` 会被折叠成 `Foo`,这条第二次登记会顺延,
/// `-2` 断言红)。
///
/// 抓不住:别的重解析点形态;也不验 8.3 短名。
#[cfg(windows)]
#[test]
fn a_verbatim_trailing_dot_path_is_not_folded() {
let root = TempRoot::new("verbatim");
foreign_minecraft(&root.game_root());
let base = pack_dir::instance_dir(&root.launcher(), "Bar");
let verbatim =
std::path::PathBuf::from(format!(r"\\?\{}", base.with_file_name("Bar.").display()));
let mut first = request("1.20.1", AdoptTarget::Custom(verbatim.clone()));
first.name = Some("First".to_string());
adopt::adopt(&root.game(), &root.launcher(), &first).expect("verbatim custom 登记成功");
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
second.name = Some("Bar".to_string());
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("默认登记成功");
assert_eq!(
b.run_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned()),
Some("Bar".to_string()),
"verbatim 的 Bar. 与普通 Bar 是两个目录,不该顺延"
);
}
+169 -3
View File
@@ -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}");
}