From 347a3cc90d85d66016f4565deb489380c65c0a5f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 23:56:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(game):=20=E5=A4=8D=E5=AE=A1=20r2=20?= =?UTF-8?q?=E4=B8=89=E9=A1=B9=E2=80=94=E2=80=94Win32=20=E5=B0=BE=E7=82=B9/?= =?UTF-8?q?=E5=B0=BE=E7=A9=BA=E6=A0=BC=E5=88=AB=E5=90=8D=20+=20=E5=86=99?= =?UTF-8?q?=E9=9D=A2=E4=B8=8E=E6=B8=B8=E6=88=8F=E6=A0=91=E9=87=8D=E5=8F=A0?= =?UTF-8?q?=20+=20=E6=9B=B4=E9=AB=98=E7=A5=96=E5=85=88=E6=96=AD=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 重解析点未覆盖。 --- crates/cli/src/cli/help.rs | 4 +- .../src/commands/instances/adopt_shared.rs | 3 + crates/cli/tests/adopt_test/cli_surface.rs | 54 +++++ .../cli/tests/adopt_test/collision_cases.rs | 94 +++++++++ crates/core/src/game/adopt.rs | 5 +- crates/core/src/game/adopt/error.rs | 13 ++ crates/core/src/game/adopt/fspaths.rs | 195 ++++++++++++++++++ crates/core/src/game/adopt/inspect.rs | 45 ++-- crates/core/src/game/adopt/plan.rs | 25 ++- crates/core/src/game/adopt/run_dir.rs | 136 ++---------- crates/core/tests/adopt/collision_cases.rs | 77 +++++++ crates/core/tests/adopt/surface_cases.rs | 172 ++++++++++++++- 12 files changed, 674 insertions(+), 149 deletions(-) create mode 100644 crates/core/src/game/adopt/fspaths.rs diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index cf04a1e..339de64 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -121,7 +121,9 @@ pub fn topic(name: &str) -> Option<&'static str> { · 清单坏了 / 读不出来 / 缺 id / id 与目录名不一致 · 缺父版本 / 父链成环(说清缺哪个、环是什么) · 条目解析到游戏根之外(符号链接/目录联接/祖先链接);versions 入口存在但读不出来(断链) - · 配置写面被链到 .ULL 之外或游戏根里(config/、config/instances/、profiles.json 的真实落点) + · 配置写面被链到 .ULL 之外(config/、config/instances/、profiles.json 的真实落点) + · 写盘面与游戏树重叠(.ULL / data / 缺省运行目录解析后落进别人的 .minecraft)⇒ 换不重叠的数据目录; + 整体迁移到游戏树之外仍允许 · 展示名已被别的实例占用;这个版本已经同名登记过(重复执行 = 无副作用失败) · 缺省运行目录已存在且非空(空目录可复用;不猜「合并进已有实例」) · 缺省目录会撞上别的实例(slug 同名 / 大小写不敏感)⇒ 自动顺延 -2/-3… 并点出撞的是谁; diff --git a/crates/cli/src/commands/instances/adopt_shared.rs b/crates/cli/src/commands/instances/adopt_shared.rs index 2b24702..8ff98f5 100644 --- a/crates/cli/src/commands/instances/adopt_shared.rs +++ b/crates/cli/src/commands/instances/adopt_shared.rs @@ -63,6 +63,9 @@ pub(super) fn map_error(error: AdoptError) -> CliError { AdoptError::NoFreeRunDir { .. } => Some( "换一个 --name(缺省目录按名字派生),或用 --isolation custom --game-dir <空目录>", ), + AdoptError::WriteSurfaceInGameTree { .. } => Some( + "数据目录(.ULL / --data-dir)和游戏树重叠了:把它指到游戏树之外的目录再登记 (整体迁移到别处是允许的,默认登记不会把实例数据写进别人的 .minecraft)", + ), AdoptError::ConfigSurfaceUnsafe { .. } => Some( "数据根里的 config/ 或 config/instances/ 被链到别处了:先把它恢复成真实目录再登记 (整体迁移请把整个 .ULL 目录一起搬走,别只链其中一层)", ), diff --git a/crates/cli/tests/adopt_test/cli_surface.rs b/crates/cli/tests/adopt_test/cli_surface.rs index 8082484..ddf3c02 100644 --- a/crates/cli/tests/adopt_test/cli_surface.rs +++ b/crates/cli/tests/adopt_test/cli_surface.rs @@ -100,3 +100,57 @@ fn help_lists_the_new_subcommands_next_to_the_old_ones() { let (code, stdout, _) = run_cli(&args); assert_eq!(code, 1, "`per_version` 不是 CLI 词表:{stdout}"); } + +/// **更高祖先断链**(r2 R2.3 的 CLI 面):`--data-dir` 指到迁移联接下面的 nested 目录, +/// 联接目标被搬走后 `instances scan` 必须非零 + 结构化错误,不能 `ok:true / total:0`。 +/// +/// 抓得住:只判 `versions`/游戏根两层(更高层断链会漏,这里会看到 exit0 与 total 0)。 +/// +/// 抓不住:真正的权限不足(EACCES)那一支。 +#[cfg(windows)] +#[test] +fn a_dangling_ancestor_is_a_structured_scan_error() { + let dir = TempDir::new("scan-ancestor-dangling"); + let outer = TempDir::new("scan-ancestor-target"); + let real = outer.path(); + 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 = dir.path().join("game-parent-link"); + let ok = std::process::Command::new("cmd") + .args(["/c", "mklink", "/J"]) + .arg(&link) + .arg(real) + .output() + .is_ok_and(|out| out.status.success()); + if !ok { + eprintln!("[link-guard] 建不出目录联接,跳过祖先断链 CLI 用例"); + return; + } + let data_arg = link.join("nested").display().to_string(); + + // 断开之前:能扫到 1 个候选。 + let (code, stdout, stderr) = run_cli(&["instances", "scan", "--json", "--data-dir", &data_arg]); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + assert_eq!(parse_json(&stdout)["data"]["counts"]["total"], 1); + + // 断开:联接目标改名(入口还在,versions 与游戏根都 NotFound)。 + let moved = dir.path().join("moved-target"); + std::fs::rename(real, &moved).expect("移走联接目标"); + + let (code, stdout, stderr) = run_cli(&["instances", "scan", "--json", "--data-dir", &data_arg]); + assert_eq!( + code, 1, + "祖先断链必须非零退出:stdout={stdout} stderr={stderr}" + ); + let value = parse_json(&stdout); + assert_eq!(value["ok"], false, "{stdout}"); + assert_eq!( + value["error"]["code"], "adopt_versions_unreadable", + "{stdout}" + ); +} diff --git a/crates/cli/tests/adopt_test/collision_cases.rs b/crates/cli/tests/adopt_test/collision_cases.rs index e0573a4..59616d6 100644 --- a/crates/cli/tests/adopt_test/collision_cases.rs +++ b/crates/cli/tests/adopt_test/collision_cases.rs @@ -99,3 +99,97 @@ stderr={stderr}" ); } } + +/// **Win32 尾点/尾空格**(r2 R2.1 的 CLI 面):两次登记 + 两条 `launch --dry-run` 不许落到同一物理目录。 +/// +/// 抓得住:认领目录的尾点/尾空格不归一(第二次默认登记仍拿到 `Foo`,`run_dir` 相同断言红)。 +/// +/// 抓不住:`\?\` verbatim 写法(那本来就不折叠,是另一个目录)。 +#[cfg(windows)] +#[test] +fn win32_trailing_dot_or_space_claims_do_not_share_a_run_dir() { + for suffix in [".", " "] { + let dir = TempDir::new("adopt-win32-alias"); + let root = dir.path(); + foreign_data_root(root); + let dir_arg = dir.arg(); + let claimed = root + .join(".ULL/data/instances") + .join(format!("Foo{suffix}")); + let claimed_arg = claimed.display().to_string(); + + let first = run_cli(&[ + "instances", + "adopt", + "1.20.1", + "--name", + "First", + "--isolation", + "custom", + "--game-dir", + claimed_arg.as_str(), + "--json", + "--data-dir", + dir_arg.as_str(), + ]); + assert_eq!( + first.0, 0, + "custom 登记应成功:stdout={} stderr={}", + first.1, first.2 + ); + let first = parse_json(&first.1); + + let second = run_cli(&[ + "instances", + "adopt", + "1.20.1", + "--name", + "Foo", + "--json", + "--data-dir", + dir_arg.as_str(), + ]); + assert_eq!( + second.0, 0, + "默认登记应成功:stdout={} stderr={}", + second.1, second.2 + ); + let second = parse_json(&second.1); + + let first_dir = first["data"]["instance"]["run_dir"] + .as_str() + .expect("run_dir A") + .to_string(); + let second_dir = second["data"]["instance"]["run_dir"] + .as_str() + .expect("run_dir B") + .to_string(); + assert_ne!( + first_dir, second_dir, + "尾点/尾空格别名不许共用运行目录(suffix={suffix:?})" + ); + + // 两条启动计划的 --gameDir 也必须分开(argv 已脱敏,比尾段)。 + for (value, expect) in [(&first, &first_dir), (&second, &second_dir)] { + let id = value["data"]["instance"]["id"] + .as_str() + .expect("instance id"); + let args = [ + "launch", + id, + "--dry-run", + "--json", + "--data-dir", + dir_arg.as_str(), + ]; + let (code, stdout, stderr) = run_cli(&args); + assert_eq!(code, 0, "stdout={stdout} stderr={stderr}"); + let plan = parse_json(&stdout); + assert_eq!( + plan["data"]["game_dir"].as_str().expect("game_dir"), + expect.as_str(), + "启动计划必须落在自己的运行目录(suffix={suffix:?})" + ); + } + } +} diff --git a/crates/core/src/game/adopt.rs b/crates/core/src/game/adopt.rs index d4c7da7..a91b375 100644 --- a/crates/core/src/game/adopt.rs +++ b/crates/core/src/game/adopt.rs @@ -31,7 +31,9 @@ //! - `error`:整次调用失败([`AdoptError`],带稳定错误码); //! - `inspect`:只读扫描与逐项检查([`scan`] / [`inspect`]); //! - `paths`:路径判据(版本 id 能不能当路径、符号链接有没有跑出游戏根); -//! - `run_dir`:运行目录派生(含**碰撞消歧**)与写盘边界(数据区 / 配置写面); +//! - `fspaths`:路径语义(真实化 / Win32 尾点尾空格归一)与写盘面判据(配置在 `.ULL` 内、 +//! 默认写盘面不得与受保护游戏树重叠); +//! - `run_dir`:运行目录派生(含**碰撞消歧**)与「缺省目录必须落在数据区」; //! - `plan`:把候选 + 用户选择算成一次登记的落点([`plan`]),真正落盘是 [`adopt`]。 //! //! # 不做什么 @@ -45,6 +47,7 @@ pub mod candidate; mod error; +mod fspaths; mod inspect; mod paths; mod plan; diff --git a/crates/core/src/game/adopt/error.rs b/crates/core/src/game/adopt/error.rs index e542455..79c3102 100644 --- a/crates/core/src/game/adopt/error.rs +++ b/crates/core/src/game/adopt/error.rs @@ -68,6 +68,15 @@ pub enum AdoptError { /// 人读原因(含解析后的真实位置)。 detail: String, }, + /// 默认写盘面(`.ULL`/`data`/配置/缺省运行目录)解析后落在**受保护游戏树**之内(r2 R2.2)。 + /// + /// 与 [`Self::ConfigSurfaceUnsafe`] 的分工:那条管「配置跑出 `.ULL`」,这条管「写盘面与游戏树重叠」。 + WriteSurfaceInGameTree { + /// 出问题的写盘面(`.ULL`、`data/`、配置目录、缺省运行目录…)。 + path: PathBuf, + /// 人读原因(含解析后的真实位置与「请换不重叠的数据目录」的指引)。 + detail: String, + }, /// `versions` 入口存在但读不出来(断链的符号链接/目录联接、目标被移走、祖先断链)。 /// /// 与「`versions` 真不存在」严格区分:后者才是空结果,前者必须具名报错。 @@ -93,6 +102,7 @@ impl AdoptError { Self::TargetInvalid { .. } => "adopt_target_invalid", Self::NoFreeRunDir { .. } => "adopt_no_free_run_dir", Self::ConfigSurfaceUnsafe { .. } => "adopt_config_surface_unsafe", + Self::WriteSurfaceInGameTree { .. } => "adopt_write_surface_in_game_tree", Self::VersionsDirUnreadable { .. } => "adopt_versions_unreadable", } } @@ -156,6 +166,9 @@ impl fmt::Display for AdoptError { Self::ConfigSurfaceUnsafe { path, detail } => { write!(f, "配置写面不可信:{} {detail}", path.display()) } + Self::WriteSurfaceInGameTree { path, detail } => { + write!(f, "写盘面与游戏树重叠:{} {detail}", path.display()) + } Self::VersionsDirUnreadable { path, detail } => write!( f, "versions 入口存在但读不出来 {}:{detail}", diff --git a/crates/core/src/game/adopt/fspaths.rs b/crates/core/src/game/adopt/fspaths.rs new file mode 100644 index 0000000..7cc6651 --- /dev/null +++ b/crates/core/src/game/adopt/fspaths.rs @@ -0,0 +1,195 @@ +//! 路径语义与**写盘面**判据:真实化、Win32 别名归一、配置写面、与受保护游戏树的重叠。 +//! +//! # 为什么单独一层 +//! +//! 「这个路径到底是哪个物理位置」在 Windows 上不是字符串比较:普通路径的**尾点/尾空格会被 Win32 +//! 折叠**(`Foo.` 与 `Foo ` 都落到 `Foo`),而 verbatim 路径(`\\?\…`)**不**折叠。`canonicalize` +//! 只能回答「已存在的东西在哪」,尚不存在的尾段(`custom` 档认领的目录、派生出来还没建的缺省目录) +//! 必须自己按平台语义归一,否则「两个实例共用同一个物理目录」会从这个缝里漏过去(复审 r2 的 R2.1)。 +//! +//! 另一件事是**写盘面**:登记承诺「只写 ULL 自己的配置、外部游戏树零写入」。判据分两条: +//! 1. 配置落点必须在 `<.ULL>` 内([`ensure_config_write_surface`]); +//! 2. 全部默认写盘面(`.ULL` 自身、`config/`、`config/instances/`、`profiles.json`、`data/` +//! 以及缺省运行目录)**不得与受保护游戏树重叠**([`ensure_surfaces_clear_of_game_tree`])。 +//! 用户把整个 `.ULL`/`data` 迁到游戏树外是合法迁移,照样通过;重叠则具名拒绝—— +//! 不能写完之后再补一句 warning(复审 r2 的 R2.2)。 +//! +//! # 已知边界 +//! +//! 判据都是「检查那一刻」的快照(TOCTOU 看不到);Win32 的 8.3 短名别名不在归一范围内 +//! (未就绪的短名无法可靠推导),非 junction 的重解析点也未逐个识别。 + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use super::super::layout::GameLayout; +use super::error::AdoptError; +use crate::platform::layout::LauncherLayout; + +/// **最近**的已存在祖先/自身解析出的真实路径 + 尚不存在的尾段(`None` = 整条链都不存在)。 +/// +/// 用于**位置判据**(越界比较、写面重叠):这里不做别名折叠——问的是「物理上在哪」。 +pub(super) fn existing_real(path: &Path) -> Option { + resolve(path, false) +} + +/// **比较键**用的路径:已存在部分真实化,尚不存在的尾段按平台语义归一(Windows 折叠尾点/尾空格, +/// verbatim 路径除外)。两个不同写法指向同一个物理目录时,键必须相等。 +pub(super) fn comparison_real(path: &Path) -> Option { + resolve(path, true) +} + +fn resolve(path: &Path, fold_tail: bool) -> Option { + let verbatim = is_verbatim(path); + let mut probe = path.to_path_buf(); + let mut tail: Vec = Vec::new(); + loop { + if std::fs::symlink_metadata(&probe).is_ok() { + let real = probe.canonicalize().ok()?; + let mut full = real; + for part in tail.iter().rev() { + if fold_tail { + full.push(platform_component(part, verbatim)); + } else { + full.push(part); + } + } + return Some(full); + } + let name = probe.file_name().map(OsString::from)?; + tail.push(name); + if !probe.pop() { + return None; + } + } +} + +/// 整条路径是不是 verbatim 形式(`\\?\…` / `\\.\…`):那种写法**不**折叠尾点/尾空格。 +fn is_verbatim(path: &Path) -> bool { + #[cfg(windows)] + { + let text = path.to_string_lossy(); + text.starts_with(r"\\?\") || text.starts_with(r"\\.\") + } + #[cfg(not(windows))] + { + let _ = path; + false + } +} + +/// 尚不存在的一段名字按**平台语义**归一。 +/// +/// Windows(非 verbatim):去掉尾部的 `.` 与空格——Win32 建目录/解析路径时就这么做,所以 +/// `Foo.`、`Foo `、`Foo` 是同一个物理目录。全是点/空格的名字保持原样(那种名字本身建不出来, +/// 折叠成空串只会制造新的歧义)。其它平台原样返回(`Foo.` 与 `Foo` 是不同目录)。 +fn platform_component(name: &std::ffi::OsStr, verbatim: bool) -> OsString { + #[cfg(windows)] + { + if !verbatim { + let text = name.to_string_lossy(); + let trimmed = text.trim_end_matches(['.', ' ']); + if !trimmed.is_empty() && trimmed.len() != text.len() { + return OsString::from(trimmed.to_string()); + } + } + } + let _ = verbatim; + name.to_os_string() +} + +/// 路径 → 比较键(`/` 分隔 + 大小写归一;大小写只在大小写不敏感的平台折叠)。 +pub(super) fn normalize_key(path: &Path) -> String { + let text = path.to_string_lossy().replace('\\', "/"); + if cfg!(any(windows, target_os = "macos")) { + text.to_lowercase() + } else { + text + } +} + +/// 配置写面(`config/`、`config/instances/`、`profiles.json`)必须在 `<.ULL>` 之内。 +/// +/// 只管「在不在自己家里」;「有没有落进受保护游戏树」由 +/// [`ensure_surfaces_clear_of_game_tree`] 统一负责(两条判据各有单一职责,不再互相豁免)。 +/// +/// # Errors +/// +/// 落点跑出 `<.ULL>`:`ConfigSurfaceUnsafe`。 +pub(super) fn ensure_config_write_surface(launcher: &LauncherLayout) -> Result<(), AdoptError> { + let Some(home_real) = existing_real(launcher.home()) else { + // `<.ULL>` 都还不存在 ⇒ 还不可能有链接可言。 + return Ok(()); + }; + let targets = [ + launcher.config(), + launcher.config().join("instances"), + launcher.profiles_config(), + ]; + for target in targets { + let Some(real) = existing_real(&target) else { + continue; + }; + if !real.starts_with(&home_real) { + return Err(AdoptError::ConfigSurfaceUnsafe { + path: target, + detail: format!( + "解析到 {},落在启动器目录 {} 之外(有指向外部的符号链接/目录联接)", + real.display(), + home_real.display() + ), + }); + } + } + Ok(()) +} + +/// 默认写盘面**不得**与受保护游戏树重叠(`.ULL`/`data`/配置/缺省运行目录解析后都不许落进游戏根)。 +/// +/// 为什么要它:用户可能预先在数据根里放过目录联接(不是 adopt 建的)。这时「默认独立目录」写着写着 +/// 就写进了别人的 `.minecraft`,输出却还说「与别人的 .minecraft 隔离」——那是**假承诺**。 +/// 判据在**写入之前**给出具名拒绝,并让用户把数据目录挪到游戏树之外;整体迁移到游戏树**之外**照旧允许。 +/// +/// `run_dir` 只在缺省档(`InstanceDir`)传:`shared` / `version_dir` / `custom` 是用户显式选择的 +/// 运行目录,允许与游戏树重叠(那是选择的语义,不是偷偷写的)。 +/// +/// # Errors +/// +/// 任一面解析后落进游戏根:`WriteSurfaceInGameTree`。 +pub(super) fn ensure_surfaces_clear_of_game_tree( + game: &GameLayout, + launcher: &LauncherLayout, + run_dir: Option<&Path>, +) -> Result<(), AdoptError> { + let Some(game_real) = existing_real(game.root()) else { + // 游戏根还不存在 ⇒ 没有「受保护游戏树」可重叠。 + return Ok(()); + }; + let mut surfaces = vec![ + launcher.home().to_path_buf(), + launcher.config(), + launcher.config().join("instances"), + launcher.profiles_config(), + launcher.data(), + ]; + if let Some(run_dir) = run_dir { + surfaces.push(run_dir.to_path_buf()); + } + for surface in surfaces { + let Some(real) = existing_real(&surface) else { + continue; + }; + if real.starts_with(&game_real) { + return Err(AdoptError::WriteSurfaceInGameTree { + path: surface, + detail: format!( + "解析到 {},落在受保护游戏树 {} 之内:默认登记不会把实例数据写进别人的 .minecraft;\ + 请把 .ULL(或 --data-dir)指到游戏树之外的目录再登记(整体迁移本身是合法的)", + real.display(), + game_real.display() + ), + }); + } + } + Ok(()) +} diff --git a/crates/core/src/game/adopt/inspect.rs b/crates/core/src/game/adopt/inspect.rs index 5afecd8..4b470d8 100644 --- a/crates/core/src/game/adopt/inspect.rs +++ b/crates/core/src/game/adopt/inspect.rs @@ -51,21 +51,21 @@ pub fn scan(game: &GameLayout, launcher: &LauncherLayout) -> Result { // **入口还在但读不出来**不是「新数据根」:Windows 上断链的目录联接也只会给 NotFound, // 必须自己用不跟随链接的元数据分辨(复审 r1 的实测:lstat 成功而命令报「空」)。 - if std::fs::symlink_metadata(&versions_dir).is_ok() { - return Err(AdoptError::VersionsDirUnreadable { - path: versions_dir, - detail: "入口存在但读不出来(断链的符号链接/目录联接,或目标被移走)" - .to_owned(), - }); - } - // 祖先断链:游戏根本身存在却解析不出去,同样是异常而不是「还没有版本」。 - let root = game.root(); - if std::fs::symlink_metadata(root).is_ok() && root.canonicalize().is_err() { - return Err(AdoptError::VersionsDirUnreadable { - path: root.to_path_buf(), - detail: "游戏根存在但解析失败(祖先链断链)".to_owned(), - }); + // + // r2 R2.3:断链可能发生在**更高的祖先**上(例如 `--data-dir` 指向迁移联接下面的 nested + // 目录,联接目标被搬走后 `versions` 与游戏根本身都 NotFound)。所以沿祖先找**最近的 + // 已存在入口**:它 lstat 得到但 `canonicalize` 不出来 ⇒ 具名报错;一路到盘根都不存在 + // ⇒ 才是「真正的新数据根」,空结果是诚实的。 + if let Some(entry) = nearest_existing_entry(&versions_dir) { + if entry.canonicalize().is_err() { + return Err(AdoptError::VersionsDirUnreadable { + path: entry, + detail: "最近的已存在入口解析失败(断链的符号链接/目录联接,或目标被移走)" + .to_owned(), + }); + } } + return Ok(AdoptScan { versions_dir, candidates: Vec::new(), @@ -381,3 +381,20 @@ fn broken_link_candidate(version_id: &str, path: &Path) -> AdoptCandidate { }], } } + +/// 沿祖先找**最近的已存在入口**(自身优先;一路到盘根都没有 ⇒ `None`)。 +/// +/// 用途:`read_dir` 的 `NotFound` 不代表「这个数据根是空的」——断链可能发生在更高的祖先上 +/// (迁移联接的目标被搬走时,`versions` 和游戏根都 NotFound,但入口还在)。判据是**存在性** +/// (`symlink_metadata`,不跟随),能不能解析由调用方另行判定。 +fn nearest_existing_entry(path: &Path) -> Option { + let mut probe = path.to_path_buf(); + loop { + if std::fs::symlink_metadata(&probe).is_ok() { + return Some(probe); + } + if !probe.pop() { + return None; + } + } +} diff --git a/crates/core/src/game/adopt/plan.rs b/crates/core/src/game/adopt/plan.rs index 0f135c2..d0e85d2 100644 --- a/crates/core/src/game/adopt/plan.rs +++ b/crates/core/src/game/adopt/plan.rs @@ -9,7 +9,7 @@ //! 已被**别的实例**占着 ⇒ 顺延 `-2`/`-3` 后缀,绝不让两个实例共用同一个运行目录)。 //! //! 写配置**之前**还要核一遍真实写面(`config/`、`config/instances/`、`profiles.json` 必须在 -//! `<.ULL>` 之内且不落进游戏根,见 [`run_dir::ensure_config_write_surface`])。 +//! 写盘面判据见 [`fspaths`](配置必须在 `<.ULL>` 内、默认写盘面不得与游戏树重叠)。 //! //! `versions/` `libraries/` `assets/` **一个字节都不写**:登记只填 `version_id`,内容靠 D20 的 //! 「恒共享层」被启动/下载链路复用([`crate::game::isolation::SHARED_LAYERS`])。 @@ -21,6 +21,7 @@ use super::super::layout::GameLayout; use super::super::profiles::ProfileList; use super::candidate::{AdoptCandidate, AdoptTarget}; use super::error::AdoptError; +use super::fspaths; use super::inspect::inspect; use super::run_dir; use crate::platform::layout::LauncherLayout; @@ -108,10 +109,12 @@ pub fn plan( launcher: &LauncherLayout, request: &AdoptRequest, ) -> Result { - // 配置写面必须在 `<.ULL>` 内、且不落进游戏根(R2):dry-run 也要报,别让预检说「可以」。 - // 放在 `inspect` 之前是有意的——检查候选的读盘也会穿过 `config/`(`profiles.json`), - // 写面不可信时先停手,连读都不必走到下发。 - run_dir::ensure_config_write_surface(game, launcher)?; + // 写盘面两道判据都放在 `inspect` 之前(dry-run 也要报,别让预检说「可以」): + // ① 默认写盘面不得与**受保护游戏树**重叠(r2 R2.2;整体迁移到游戏树外照旧允许); + // ② 配置落点必须在 `<.ULL>` 内(r1 R2)。 + // 放前面是有意的——检查候选的读盘也会穿过 `config/`(`profiles.json`),写面不可信时先停手。 + fspaths::ensure_surfaces_clear_of_game_tree(game, launcher, None)?; + fspaths::ensure_config_write_surface(launcher)?; // `inspect` 只在候选**可登记**时返回 `Ok`,不合格一律是 `NotAdoptable`(问题清单已带出)。 let candidate = inspect(game, launcher, &request.version_id)?; let name = resolve_name(request.name.as_deref(), &candidate.version_id); @@ -151,6 +154,10 @@ pub fn plan( &profiles, )?; notes.extend(bump_notes); + // 缺省运行目录也要落在游戏树之外(`shared`/`version_dir`/`custom` 是显式选择,不复检)。 + if request.target == AdoptTarget::InstanceDir { + fspaths::ensure_surfaces_clear_of_game_tree(game, launcher, Some(&run_dir))?; + } notes.extend(target_notes(&request.target, &run_dir)); Ok(AdoptPlan { @@ -174,8 +181,12 @@ pub fn adopt( request: &AdoptRequest, ) -> Result { let plan = plan(game, launcher, request)?; - // `plan` 已核过一次;写盘前再核一次(plan 与写入之间可能有别人动过树),失败就一个字节都不写。 - run_dir::ensure_config_write_surface(game, launcher)?; + // `plan` 已核过;写盘前再核一次(中间可能有别人动过树),失败就一个字节都不写。 + fspaths::ensure_surfaces_clear_of_game_tree(game, launcher, None)?; + if plan.target == AdoptTarget::InstanceDir { + fspaths::ensure_surfaces_clear_of_game_tree(game, launcher, Some(&plan.run_dir))?; + } + fspaths::ensure_config_write_surface(launcher)?; let mut dir_created = false; // 目录先建:建不出来就一个配置都不写(失败的残留是一个空目录,可复用)。 if plan.target == AdoptTarget::InstanceDir && !plan.run_dir.is_dir() { diff --git a/crates/core/src/game/adopt/run_dir.rs b/crates/core/src/game/adopt/run_dir.rs index bff5582..1e93fff 100644 --- a/crates/core/src/game/adopt/run_dir.rs +++ b/crates/core/src/game/adopt/run_dir.rs @@ -6,9 +6,9 @@ //! (`Better MC` 与 `Better-MC` 经 slug 同名;Windows 上 `Foo` 与 `foo` 是同一个目录)。 //! 所以「目录是空的」**不足以**判可复用——还要看**已有实例已经占着哪个真实目录**; //! 撞上了就顺延到 `-2`、`-3`…(原因写进 notes),而不是让两个实例共用一个。 -//! 2. **只写 ULL 自己的配置**(R2):`config/`、`config/instances/`、`profiles.json` 的实际落点 -//! 必须在 `<.ULL>` 解析后的位置之内,且不得落进游戏根——否则 `config/instances` 被一个目录联接 -//! 指到 `.minecraft/config` 时,登记会替别人的启动器造 UUID 配置文件。 +//! 2. **只写 ULL 自己的配置**:配置写面与「默认写盘面不得与受保护游戏树重叠」两条判据在 +//! [`super::fspaths`](`ensure_config_write_surface` / `ensure_surfaces_clear_of_game_tree`), +//! 本模块只负责**运行目录派生**与「缺省目录必须落在数据区」这一条。 //! //! # 判据的基准(为什么不拿 `/instances` 或 `<.ULL>/config` 当基准) //! @@ -21,14 +21,13 @@ //! 判据是「检查那一刻」的快照:检查与 `create_dir_all`/写盘之间被人换掉组件(TOCTOU)看不到。 //! 本命令不是并发/对抗入口,不做跨进程锁。 -use std::ffi::OsString; use std::path::{Path, PathBuf}; use super::super::install::instance::read_instance_config; use super::super::install::pack_dir; -use super::super::layout::GameLayout; use super::super::profiles::ProfileList; use super::error::AdoptError; +use super::fspaths; use crate::platform::layout::LauncherLayout; /// 派生目录撞车时最多顺延多少个后缀(`-2` … `-33`)。撞满即报错,不无限找。 @@ -154,11 +153,11 @@ fn with_suffix(base: &Path, suffix: u32) -> PathBuf { /// /// 解析后跑出数据根(`TargetInvalid`)。 pub(super) fn ensure_inside_data(launcher: &LauncherLayout, dir: &Path) -> Result<(), AdoptError> { - let Some(data_real) = existing_real(&launcher.data()) else { + let Some(data_real) = fspaths::existing_real(&launcher.data()) else { // 连数据根都还不存在 ⇒ 不可能已经有指向外面的链接。 return Ok(()); }; - if let Some(dir_real) = existing_real(dir) { + if let Some(dir_real) = fspaths::existing_real(dir) { if !dir_real.starts_with(&data_real) { return Err(AdoptError::TargetInvalid { detail: format!( @@ -173,83 +172,6 @@ pub(super) fn ensure_inside_data(launcher: &LauncherLayout, dir: &Path) -> Resul Ok(()) } -/// 配置写面(`config/`、`config/instances/`、`profiles.json`)必须在 `<.ULL>` 之内,且不得落进游戏根。 -/// -/// 为什么需要:`write_atomic` 会 `create_dir_all(parent)` 再 `rename`,沿已存在的目录联接走是 -/// 基线的既定行为;但 **adopt 承诺「只写 ULL 自己的配置」**,所以这一层必须自己核真实落点。 -/// 拒绝时**一个字节都不写**(在 `plan` 阶段就返回结构化错误)。 -/// -/// # Errors -/// -/// 落点跑出 `<.ULL>` 或落进游戏根:`ConfigSurfaceUnsafe`。 -pub(super) fn ensure_config_write_surface( - game: &GameLayout, - launcher: &LauncherLayout, -) -> Result<(), AdoptError> { - let Some(home_real) = existing_real(launcher.home()) else { - // `<.ULL>` 都还不存在 ⇒ 还不可能有链接可言。 - return Ok(()); - }; - let game_real = existing_real(game.root()); - // 判据的**真实归属**解释(不是「特殊布局豁免」): - // - 主判据:真实落点必须在 `<.ULL>` 之内。落在外面一律拒绝,并按真实目标分类给原因; - // - 附加判据:`.ULL` 与 `.minecraft` **并列**时,落点还必须在游戏根之外——挡住 - // `config/instances -> /config` 这种「没跑出 .ULL 的亲戚、但确实落进游戏根」的绕法; - // - 若 `.ULL` 自己就在游戏根里(用户把 `core.game_home` 指到启动器根这种布局), - // 「`.ULL` 内的落点也在游戏根内」是布局事实,附加判据在该布局下**没有判别力** - // (主判据已经把它包含进来了),所以只在 `.ULL` 不在游戏根内时启用;这不是给链接开后门: - // 任何跑出 `.ULL` 的落点都由主判据拦下。 - let home_inside_game = game_real - .as_ref() - .is_some_and(|game_real| home_real.starts_with(game_real)); - let targets = [ - launcher.config(), - launcher.config().join("instances"), - launcher.profiles_config(), - ]; - for target in targets { - let Some(real) = existing_real(&target) else { - continue; - }; - let in_game = game_real - .as_ref() - .is_some_and(|game_real| real.starts_with(game_real)); - if !real.starts_with(&home_real) { - let detail = if in_game { - format!( - "解析到 {},经链接落进了游戏根 {}(本启动器只写自己的配置,绝不在别人的 .minecraft 里造文件)", - real.display(), - game_real.as_ref().map_or_else(|| "<游戏根>".to_owned(), |p| p.display().to_string()) - ) - } else { - format!( - "解析到 {},落在启动器目录 {} 之外(有指向外部的符号链接/目录联接)", - real.display(), - home_real.display() - ) - }; - return Err(AdoptError::ConfigSurfaceUnsafe { - path: target, - detail, - }); - } - if home_inside_game || !in_game { - continue; - } - return Err(AdoptError::ConfigSurfaceUnsafe { - path: target, - detail: format!( - "解析到 {},落进了游戏根 {}(本启动器只写自己的配置,绝不在别人的 .minecraft 里造文件)", - real.display(), - game_real - .as_ref() - .map_or_else(|| "<游戏根>".to_owned(), |p| p.display().to_string()) - ), - }); - } - Ok(()) -} - /// `custom` 运行目录的用法判据(绝对路径 / 非空)。 /// /// # Errors @@ -274,54 +196,22 @@ pub(super) fn directory_occupied(path: &Path) -> bool { std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_some()) } -/// **比较用**的真实路径键:解析链接 + 拼回尚不存在的尾段 + 统一分隔符 + 大小写归一。 +/// **比较用**的真实路径键:真实化 + 平台别名归一(Win32 尾点/尾空格,见 [`fspaths`])+ 大小写归一。 /// /// 大小写归一只在大小写不敏感的平台做(Windows / 默认 macOS):`Foo` 与 `foo` 是同一个目录, /// 不归一会让「两个实例共用目录」这一条在 Windows 上漏掉(复审 r1 的实测之一)。 +/// **Windows 上还要折叠尾点/尾空格**(`Foo.` / `Foo ` 都是 `Foo`)——否则「尚不存在的认领目录」 +/// 会从这条缝里绕过碰撞判定(复审 r2 的 R2.1)。verbatim 路径(`\?\…`)不折叠,交给平台语义。 /// /// **策略是保守的**:macOS 也可能是大小写敏感的 APFS 卷,那时 `Foo` 与 `foo` 本来是**不同**目录, /// 归一后会判成碰撞 ⇒ 顺延 `-2`。代价是目录名可能多一个后缀,换来的是「绝不共用」这条不变量 /// 在任何卷上都成立(判错的方向只能是「多一次顺延」,不能是「共用一个目录」)。 /// -/// **目标可能还不存在**:这里不要求路径存在([`existing_real`] 会拼回尚不存在的尾段), +/// **目标可能还不存在**:这里不要求路径存在([`fspaths::comparison_real`] 会拼回尚不存在的尾段), /// 因为「实例已经认领了、但目录还没被创建」(`custom` 档就是这种)同样必须参与比较。 fn dir_key(path: &Path) -> String { - match existing_real(path) { - Some(real) => normalize_key(&real), - None => normalize_key(path), - } -} - -/// 路径 → 比较键(`/` 分隔 + 大小写归一)。 -fn normalize_key(path: &Path) -> String { - let text = path.to_string_lossy().replace('\\', "/"); - if cfg!(any(windows, target_os = "macos")) { - text.to_lowercase() - } else { - text - } -} - -/// **最近**的已存在祖先/自身解析出的真实路径 + 尚不存在的尾段(`None` = 整条链都不存在)。 -/// -/// 目标本身不存在时**保留**它的尾段:否则 `instances/Foo` 与 `instances/foo` 都会退化成 -/// `instances` 的真实路径,两个不同目录被当成同一个(或反过来永远判不相等)。 -pub(super) fn existing_real(path: &Path) -> Option { - let mut probe = path.to_path_buf(); - let mut tail: Vec = Vec::new(); - loop { - if std::fs::symlink_metadata(&probe).is_ok() { - let real = probe.canonicalize().ok()?; - let mut full = real; - for part in tail.iter().rev() { - full.push(part); - } - return Some(full); - } - let name = probe.file_name().map(OsString::from)?; - tail.push(name); - if !probe.pop() { - return None; - } + match fspaths::comparison_real(path) { + Some(real) => fspaths::normalize_key(&real), + None => fspaths::normalize_key(path), } } diff --git a/crates/core/tests/adopt/collision_cases.rs b/crates/core/tests/adopt/collision_cases.rs index 6f49ec5..4b42304 100644 --- a/crates/core/tests/adopt/collision_cases.rs +++ b/crates/core/tests/adopt/collision_cases.rs @@ -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 是两个目录,不该顺延" + ); +} diff --git a/crates/core/tests/adopt/surface_cases.rs b/crates/core/tests/adopt/surface_cases.rs index 9ab2d47..0e14eac 100644 --- a/crates/core/tests/adopt/surface_cases.rs +++ b/crates/core/tests/adopt/surface_cases.rs @@ -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 { names.sort(); names } + +/// `.ULL/data` 被预先联接到**游戏树**上 ⇒ 默认登记必须拒绝(r2 R2.2),而不是写完再说「隔离」。 +/// +/// 复审 r2 的实跑:`data -> .minecraft` 时 dry-run 与 adopt 都 exit0,外部多出 +/// `instances/` 且游戏根 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/`),真实位置才是迁移后的目录。 + 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}"); +}