From 8110cde3f5808fcca59ec4debf9907f92cfb4113 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 23:13:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(game):=20=E5=A4=8D=E5=AE=A1=20r1=20?= =?UTF-8?q?=E4=BA=94=E9=A1=B9=E2=80=94=E2=80=94=E8=BF=90=E8=A1=8C=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E4=B8=8D=E8=AE=B8=E8=A2=AB=E4=B8=A4=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E5=85=B1=E7=94=A8=20+=20=E9=85=8D=E7=BD=AE=E5=86=99=E9=9D=A2?= =?UTF-8?q?=E8=B6=8A=E7=95=8C=20+=20versions=20=E6=96=AD=E9=93=BE=20+=20?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=AE=88=E5=8D=AB=E5=8F=8D=E8=BD=AC=20+=20?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E6=BC=8F=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 p2-5items 的五项,全部按「先能红的用例,再改实现」: R1(默认目录可被不同实例隐式共用,产品缺陷):新增 `adopt/run_dir.rs`——登记前读**每个已有实例** 的 `gameDir`(现役设置 + M27 前档案的遗留字段),用「真实路径 + 拼回不存在的尾段 + 大小写归一」 (Windows/macOS)比较;撞上就顺延 `-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 重解析点仍列边界。 --- crates/cli/src/cli/help.rs | 5 +- .../src/commands/instances/adopt_shared.rs | 9 + crates/cli/tests/adopt_test.rs | 19 +- .../cli/tests/adopt_test/collision_cases.rs | 101 ++++++ crates/cli/tests/adopt_test/fixture.rs | 79 +---- crates/core/src/game/adopt.rs | 2 + crates/core/src/game/adopt/error.rs | 38 ++ crates/core/src/game/adopt/inspect.rs | 38 +- crates/core/src/game/adopt/plan.rs | 106 ++---- crates/core/src/game/adopt/run_dir.rs | 327 ++++++++++++++++++ crates/core/tests/adopt/collision_cases.rs | 144 ++++++++ crates/core/tests/adopt/fixture.rs | 207 +++++++---- crates/core/tests/adopt/fixture_cases.rs | 141 ++++++++ crates/core/tests/adopt/main.rs | 12 +- crates/core/tests/adopt/plan_cases.rs | 10 +- crates/core/tests/adopt/register_cases.rs | 10 +- crates/core/tests/adopt/scan_cases.rs | 53 +-- crates/core/tests/adopt/snapshot.rs | 180 ++++++++++ crates/core/tests/adopt/surface_cases.rs | 154 +++++++++ 19 files changed, 1363 insertions(+), 272 deletions(-) create mode 100644 crates/cli/tests/adopt_test/collision_cases.rs create mode 100644 crates/core/src/game/adopt/run_dir.rs create mode 100644 crates/core/tests/adopt/collision_cases.rs create mode 100644 crates/core/tests/adopt/fixture_cases.rs create mode 100644 crates/core/tests/adopt/snapshot.rs create mode 100644 crates/core/tests/adopt/surface_cases.rs diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index 15b84ea..cf04a1e 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -120,9 +120,12 @@ pub fn topic(name: &str) -> Option<&'static str> { 拒绝(退出码 1,原因逐条进 error.detail.problems): · 清单坏了 / 读不出来 / 缺 id / id 与目录名不一致 · 缺父版本 / 父链成环(说清缺哪个、环是什么) - · 条目解析到游戏根之外(符号链接/目录联接/祖先链接) + · 条目解析到游戏根之外(符号链接/目录联接/祖先链接);versions 入口存在但读不出来(断链) + · 配置写面被链到 .ULL 之外或游戏根里(config/、config/instances/、profiles.json 的真实落点) · 展示名已被别的实例占用;这个版本已经同名登记过(重复执行 = 无副作用失败) · 缺省运行目录已存在且非空(空目录可复用;不猜「合并进已有实例」) + · 缺省目录会撞上别的实例(slug 同名 / 大小写不敏感)⇒ 自动顺延 -2/-3… 并点出撞的是谁; + 撞满 32 个后缀才报错(不会让两个实例共用一个运行目录) --dry-run:只检查并打印将要用的隔离档/运行目录,不写任何文件、不建目录。 --json 输出 {\"data\":{\"dry_run\":…,\"instance\":{\"id\",\"name\",\"isolation\",\"run_dir\", \"run_dir_source\",\"config_path\",\"created\"},\"notes\":[…]}}。 diff --git a/crates/cli/src/commands/instances/adopt_shared.rs b/crates/cli/src/commands/instances/adopt_shared.rs index 1e23f48..2b24702 100644 --- a/crates/cli/src/commands/instances/adopt_shared.rs +++ b/crates/cli/src/commands/instances/adopt_shared.rs @@ -60,6 +60,15 @@ pub(super) fn map_error(error: AdoptError) -> CliError { Some("换一个名字(缺省目录按名字派生),或用 --isolation custom --game-dir <空目录>") } AdoptError::UnknownVersion { .. } => Some("先看有哪些可用:%prog% instances scan"), + AdoptError::NoFreeRunDir { .. } => Some( + "换一个 --name(缺省目录按名字派生),或用 --isolation custom --game-dir <空目录>", + ), + AdoptError::ConfigSurfaceUnsafe { .. } => Some( + "数据根里的 config/ 或 config/instances/ 被链到别处了:先把它恢复成真实目录再登记 (整体迁移请把整个 .ULL 目录一起搬走,别只链其中一层)", + ), + AdoptError::VersionsDirUnreadable { .. } => Some( + "游戏根/versions 入口存在但读不出来:检查它是不是断链的符号链接/目录联接, 或把它指回真实目录后再扫", + ), _ => None, }; let cli = match hint { diff --git a/crates/cli/tests/adopt_test.rs b/crates/cli/tests/adopt_test.rs index 934ef5f..c281f94 100644 --- a/crates/cli/tests/adopt_test.rs +++ b/crates/cli/tests/adopt_test.rs @@ -6,6 +6,8 @@ #[path = "adopt_test/cli_surface.rs"] mod cli_surface; +#[path = "adopt_test/collision_cases.rs"] +mod collision_cases; #[path = "adopt_test/fixture.rs"] mod fixture; #[path = "support/mod.rs"] @@ -15,7 +17,7 @@ use std::path::Path; use support::{parse_json, run_cli, TempDir}; -use crate::fixture::{assert_unchanged, foreign_data_root, snapshot}; +use crate::fixture::{assert_unchanged, foreign_data_root, snapshot, snapshot_opt, Node}; /// 缺省登记:落 ULL 数据区的独立目录,隔离档 `custom`,**不沿用全局 `isolated`**。 /// @@ -186,6 +188,16 @@ fn dry_run_writes_nothing() { let root = dir.path(); foreign_data_root(root); let before = snapshot(root); + // 判据强度自检(R5):快照必须含**目录**节点,否则「凭空建个空目录」看不出来。 + assert!( + matches!(before.get(".minecraft"), Some(Node::Dir { .. })), + "快照缺少目录节点:{:?}", + before.get(".minecraft") + ); + assert!( + before.contains_key(".minecraft/versions"), + "快照缺少 versions 目录节点" + ); let (code, stdout, stderr) = run_cli(&[ "instances", @@ -198,6 +210,11 @@ fn dry_run_writes_nothing() { assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); let after = snapshot(root); assert_unchanged(&before, &after, "--dry-run 之后"); + // `--dry-run` 连 `.ULL` 这个**空目录**都不许建(存在性判据用 `snapshot_opt`)。 + assert!( + snapshot_opt(&root.join(".ULL")).is_none(), + "--dry-run 不该建配置目录" + ); assert!(stdout.contains("检查通过"), "{stdout}"); assert!(stdout.contains("没有写任何文件"), "{stdout}"); } diff --git a/crates/cli/tests/adopt_test/collision_cases.rs b/crates/cli/tests/adopt_test/collision_cases.rs new file mode 100644 index 0000000..e0573a4 --- /dev/null +++ b/crates/cli/tests/adopt_test/collision_cases.rs @@ -0,0 +1,101 @@ +//! 运行目录**碰撞**的 CLI 面:slug 同名 / 大小写不敏感不许让两个实例共用一个目录(复审 r1 的 R1)。 +//! +//! 从 `tests/adopt_test.rs` 拆出来只为守 400 行硬线;用例体未改。 + +use std::path::Path; + +use crate::support::{parse_json, run_cli, TempDir}; + +use crate::fixture::foreign_data_root; + +/// **slug 碰撞的两个展示名不能绑定同一个物理运行目录**(R1 的 CLI 面)。 +/// +/// 复审 r1 的实跑:`Better MC` 与 `Better-MC` 都成功、两个 UUID、却指向同一个目录, +/// 随后 `launch --dry-run` 的 `--gameDir` 也是同一个。这条把它钉住。 +/// +/// 抓得住: +/// - 登记侧不查已有实例占用的目录(两个 `run_dir` 相同,第一条断言红); +/// - `launch` 的 `game_dir` 与登记的目录不一致(第三条断言红)。 +/// +/// 抓不住:`pack_dir` 的 slug 规则本身(既有行为,本 mission 不改)。 +#[test] +fn colliding_slug_names_get_separate_run_dirs() { + let dir = TempDir::new("adopt-slug"); + let root = dir.path(); + foreign_data_root(root); + let dir_arg = dir.arg(); + + let first = run_cli(&[ + "instances", + "adopt", + "1.20.1", + "--name", + "Better MC", + "--json", + "--data-dir", + dir_arg.as_str(), + ]); + assert_eq!( + first.0, 0, + "第一次登记应成功:stdout={} stderr={}", + first.1, first.2 + ); + let first = parse_json(&first.1); + let second = run_cli(&[ + "instances", + "adopt", + "1.20.1", + "--name", + "Better-MC", + "--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, "两个实例不许共用运行目录"); + assert!( + Path::new(&first_dir).is_dir() && Path::new(&second_dir).is_dir(), + "两个运行目录都要真实存在" + ); + + // 真启动计划(dry-run)也要分开:两条实例各自的 game_dir 不同。 + 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}" + ); + assert_eq!( + parse_json(&stdout)["data"]["game_dir"] + .as_str() + .expect("game_dir"), + expect.as_str() + ); + } +} diff --git a/crates/cli/tests/adopt_test/fixture.rs b/crates/cli/tests/adopt_test/fixture.rs index 7477dd1..3a3e70d 100644 --- a/crates/cli/tests/adopt_test/fixture.rs +++ b/crates/cli/tests/adopt_test/fixture.rs @@ -1,10 +1,15 @@ -//! CLI 侧夹具:一个「别的启动器铺好了 `.minecraft`」的数据根 + 全量快照工具。 +//! CLI 侧夹具:一个「别的启动器铺好了 `.minecraft`」的数据根。 +//! +//! 快照工具**复用 core 测试那一份**(`crates/core/tests/adopt/snapshot.rs`,全节点:目录/文件/链接 +//! 各记形状与 mtime)。以前 CLI 自己抄了一份 walk,两份都漏了目录节点——复审 r1 的 R5 正是这个洞, +//! 所以这里直接 `#[path]` 引用同一份源码:判据只有一处,不会再漂。 + +#[path = "../../../core/tests/adopt/snapshot.rs"] +mod snapshot; + +pub use snapshot::{assert_unchanged, snapshot, snapshot_opt, Node}; -use std::collections::hash_map::DefaultHasher; -use std::collections::BTreeMap; -use std::hash::{Hash, Hasher}; use std::path::Path; -use std::time::UNIX_EPOCH; /// 造一份「别的启动器已经铺好的」数据根(`/.minecraft` + `/.ULL`): /// @@ -36,70 +41,6 @@ pub fn foreign_data_root(root: &Path) { ); } -/// 全量快照:相对路径 → (字节数, mtime 纳秒, 内容哈希)。 -pub fn snapshot(dir: &Path) -> BTreeMap { - let mut out = BTreeMap::new(); - walk(dir, dir, &mut out); - out -} - -fn walk(root: &Path, dir: &Path, out: &mut BTreeMap) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - let Ok(meta) = std::fs::symlink_metadata(&path) else { - continue; - }; - if meta.is_dir() { - walk(root, &path, out); - continue; - } - let relative = path - .strip_prefix(root) - .unwrap_or(&path) - .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, - after: &BTreeMap, - what: &str, -) { - assert_eq!( - before.keys().collect::>(), - after.keys().collect::>(), - "{what}:路径集合变了" - ); - for (path, before) in before { - assert_eq!( - before, - after.get(path).expect("同一路径"), - "{what}:文件被改动 -> {path}" - ); - } -} - fn write_version(versions: &Path, id: &str, manifest: &str, jar: bool) { let dir = versions.join(id); std::fs::create_dir_all(&dir).expect("建版本目录"); diff --git a/crates/core/src/game/adopt.rs b/crates/core/src/game/adopt.rs index 8aa49cd..d4c7da7 100644 --- a/crates/core/src/game/adopt.rs +++ b/crates/core/src/game/adopt.rs @@ -31,6 +31,7 @@ //! - `error`:整次调用失败([`AdoptError`],带稳定错误码); //! - `inspect`:只读扫描与逐项检查([`scan`] / [`inspect`]); //! - `paths`:路径判据(版本 id 能不能当路径、符号链接有没有跑出游戏根); +//! - `run_dir`:运行目录派生(含**碰撞消歧**)与写盘边界(数据区 / 配置写面); //! - `plan`:把候选 + 用户选择算成一次登记的落点([`plan`]),真正落盘是 [`adopt`]。 //! //! # 不做什么 @@ -47,6 +48,7 @@ mod error; mod inspect; mod paths; mod plan; +mod run_dir; pub use candidate::{AdoptCandidate, AdoptProblem, AdoptTarget, RegisteredInstance}; pub use error::AdoptError; diff --git a/crates/core/src/game/adopt/error.rs b/crates/core/src/game/adopt/error.rs index c2033d6..e542455 100644 --- a/crates/core/src/game/adopt/error.rs +++ b/crates/core/src/game/adopt/error.rs @@ -55,6 +55,28 @@ pub enum AdoptError { /// 人读原因。 detail: String, }, + /// 缺省运行目录的后缀全被占用(`-2` … `-33` 都撞上了)——不无限顺延,直接报错。 + NoFreeRunDir { + /// 派生用的基准目录。 + base: PathBuf, + }, + /// 配置写面(`config/`、`config/instances/`、`profiles.json`)的真实落点不可信: + /// 跑出 `<.ULL>` 或落进游戏根(符号链接 / 目录联接)。 + ConfigSurfaceUnsafe { + /// 出问题的目标路径(`config/`、`config/instances/` 或 `profiles.json`)。 + path: PathBuf, + /// 人读原因(含解析后的真实位置)。 + detail: String, + }, + /// `versions` 入口存在但读不出来(断链的符号链接/目录联接、目标被移走、祖先断链)。 + /// + /// 与「`versions` 真不存在」严格区分:后者才是空结果,前者必须具名报错。 + VersionsDirUnreadable { + /// 出问题的路径(`versions/` 或游戏根)。 + path: PathBuf, + /// 人读原因。 + detail: String, + }, } impl AdoptError { @@ -69,6 +91,9 @@ impl AdoptError { Self::AlreadyRegistered { .. } => "adopt_already_registered", Self::TargetNotEmpty { .. } => "adopt_target_not_empty", Self::TargetInvalid { .. } => "adopt_target_invalid", + Self::NoFreeRunDir { .. } => "adopt_no_free_run_dir", + Self::ConfigSurfaceUnsafe { .. } => "adopt_config_surface_unsafe", + Self::VersionsDirUnreadable { .. } => "adopt_versions_unreadable", } } @@ -123,6 +148,19 @@ impl fmt::Display for AdoptError { write!(f, "运行目录已存在且非空,不覆盖:{}", path.display()) } Self::TargetInvalid { detail } => write!(f, "运行目录选择不成立:{detail}"), + Self::NoFreeRunDir { base } => write!( + f, + "为这个实例派生独立运行目录失败:{} 及其 -2…-33 后缀都被别的实例占着", + base.display() + ), + Self::ConfigSurfaceUnsafe { path, detail } => { + write!(f, "配置写面不可信:{} {detail}", path.display()) + } + Self::VersionsDirUnreadable { path, detail } => write!( + f, + "versions 入口存在但读不出来 {}:{detail}", + path.display() + ), } } } diff --git a/crates/core/src/game/adopt/inspect.rs b/crates/core/src/game/adopt/inspect.rs index 9db7b57..5afecd8 100644 --- a/crates/core/src/game/adopt/inspect.rs +++ b/crates/core/src/game/adopt/inspect.rs @@ -34,8 +34,9 @@ pub struct AdoptScan { /// 扫当前游戏根的 `versions/`,逐个检查。 /// -/// `versions/` **不存在** ⇒ 空结果(新装的数据根,没有可登记的东西,不是错误); -/// `versions/` 存在但读不了 ⇒ [`AdoptError::Game`](绝不降级成空列表)。 +/// `versions/` **真的不存在** ⇒ 空结果(新装的数据根,没有可登记的东西,不是错误); +/// `versions/` 入口**存在但读不出来**(断链的符号链接/目录联接、目标被移走、祖先断链)⇒ +/// [`AdoptError::VersionsDirUnreadable`],绝不降级成空列表;其它读目录失败 ⇒ [`AdoptError::Game`]。 /// /// # Errors /// @@ -48,10 +49,27 @@ pub fn scan(game: &GameLayout, launcher: &LauncherLayout) -> Result entries, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // **入口还在但读不出来**不是「新数据根」: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(), + }); + } return Ok(AdoptScan { versions_dir, candidates: Vec::new(), - }) + }); } Err(error) => return Err(GameError::io(&versions_dir, error).into()), }; @@ -108,9 +126,21 @@ pub fn inspect( }); } if !game.version_dir(version_id).is_dir() { + let version_dir = game.version_dir(version_id); + // 条目存在但不是可读目录(断链链接/联接、或同名的是个文件)⇒ 具名报错,别混成「没装」。 + if std::fs::symlink_metadata(&version_dir).is_ok() { + return Err(AdoptError::NotAdoptable { + version_id: version_id.to_owned(), + problems: vec![AdoptProblem::DirectoryUnreadable { + path: version_dir, + message: "条目存在但不是可读目录(断链 / 联接目标被移走 / 同名的是文件)" + .to_owned(), + }], + }); + } return Err(AdoptError::UnknownVersion { version_id: version_id.to_owned(), - version_dir: game.version_dir(version_id), + version_dir, }); } let profiles = read_profiles(launcher)?; diff --git a/crates/core/src/game/adopt/plan.rs b/crates/core/src/game/adopt/plan.rs index d80ed15..0f135c2 100644 --- a/crates/core/src/game/adopt/plan.rs +++ b/crates/core/src/game/adopt/plan.rs @@ -5,7 +5,11 @@ //! 1. `config/instances/.json`(实例设置:隔离档 + 运行目录); //! 2. `config/profiles.json`(档案列表); //! 3. 选了缺省运行目录时,`/instances/<实例目录名>` 这个空目录(已存在且非空 ⇒ 拒绝; -//! 路径上的已有祖先若解析到**数据根之外**(符号链接/目录联接)⇒ 拒绝,不沿它写盘)。 +//! 路径上的已有祖先若解析到**数据根之外**(符号链接/目录联接)⇒ 拒绝,不沿它写盘; +//! 已被**别的实例**占着 ⇒ 顺延 `-2`/`-3` 后缀,绝不让两个实例共用同一个运行目录)。 +//! +//! 写配置**之前**还要核一遍真实写面(`config/`、`config/instances/`、`profiles.json` 必须在 +//! `<.ULL>` 之内且不落进游戏根,见 [`run_dir::ensure_config_write_surface`])。 //! //! `versions/` `libraries/` `assets/` **一个字节都不写**:登记只填 `version_id`,内容靠 D20 的 //! 「恒共享层」被启动/下载链路复用([`crate::game::isolation::SHARED_LAYERS`])。 @@ -13,11 +17,12 @@ use std::path::{Path, PathBuf}; use super::super::install::instance::{read_profiles, register_with, InstanceSettings, Isolation}; -use super::super::install::pack_dir; use super::super::layout::GameLayout; +use super::super::profiles::ProfileList; use super::candidate::{AdoptCandidate, AdoptTarget}; use super::error::AdoptError; use super::inspect::inspect; +use super::run_dir; use crate::platform::layout::LauncherLayout; /// 一次登记请求(CLI / presenter 的输入形状)。 @@ -103,6 +108,10 @@ 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` 只在候选**可登记**时返回 `Ok`,不合格一律是 `NotAdoptable`(问题清单已带出)。 let candidate = inspect(game, launcher, &request.version_id)?; let name = resolve_name(request.name.as_deref(), &candidate.version_id); @@ -133,13 +142,15 @@ pub fn plan( )); } - let run_dir = run_dir_of( + let (run_dir, bump_notes) = run_dir_of( game, launcher, &candidate.version_id, &name, &request.target, + &profiles, )?; + notes.extend(bump_notes); notes.extend(target_notes(&request.target, &run_dir)); Ok(AdoptPlan { @@ -163,14 +174,17 @@ pub fn adopt( request: &AdoptRequest, ) -> Result { let plan = plan(game, launcher, request)?; + // `plan` 已核过一次;写盘前再核一次(plan 与写入之间可能有别人动过树),失败就一个字节都不写。 + run_dir::ensure_config_write_surface(game, launcher)?; let mut dir_created = false; // 目录先建:建不出来就一个配置都不写(失败的残留是一个空目录,可复用)。 if plan.target == AdoptTarget::InstanceDir && !plan.run_dir.is_dir() { + run_dir::ensure_inside_data(launcher, &plan.run_dir)?; std::fs::create_dir_all(&plan.run_dir) .map_err(|error| AdoptError::Game(crate::game::GameError::io(&plan.run_dir, error)))?; dir_created = true; // 建完再核一次:`create_dir_all` 会沿已存在的目录联接走,越过边界就不算成功。 - ensure_inside_data(launcher, &plan.run_dir)?; + run_dir::ensure_inside_data(launcher, &plan.run_dir)?; } let settings = InstanceSettings { isolation: Some(plan.isolation), @@ -202,38 +216,26 @@ pub fn adopt( } /// 运行目录的派生(缺省落点复用整合包导入的 `/instances/<实例目录名>`)。 +/// +/// 缺省档**必须**查「已有实例占着哪个真实目录」(R1):slug 与大小写不敏感都会让两个不同展示名 +/// 落到同一个物理目录上;撞上就顺延后缀(见 [`run_dir::derive_instance_dir`]),而不是让两个 +/// 实例悄悄共用一个运行目录。返回(目录,顺延提示)。 fn run_dir_of( game: &GameLayout, launcher: &LauncherLayout, version_id: &str, name: &str, target: &AdoptTarget, -) -> Result { + profiles: &ProfileList, +) -> Result<(PathBuf, Vec), AdoptError> { match target { AdoptTarget::InstanceDir => { - let dir = pack_dir::instance_dir(launcher, name); - if directory_occupied(&dir) { - return Err(AdoptError::TargetNotEmpty { path: dir }); - } - // 数据区里**已有**的祖先不许是跑出数据根的链接/联接(junction 不需要管理员权限就能建)。 - ensure_inside_data(launcher, &dir)?; - Ok(dir) - } - AdoptTarget::Shared => Ok(game.root().to_path_buf()), - AdoptTarget::VersionDir => Ok(game.version_dir(version_id)), - AdoptTarget::Custom(dir) => { - if !dir.is_absolute() { - return Err(AdoptError::TargetInvalid { - detail: format!("--game-dir 需要绝对路径,收到 `{}`", dir.display()), - }); - } - if dir.as_os_str().is_empty() { - return Err(AdoptError::TargetInvalid { - detail: "运行目录为空".to_owned(), - }); - } - Ok(dir.clone()) + let claims = run_dir::claims(launcher, profiles)?; + run_dir::derive_instance_dir(launcher, name, &claims) } + AdoptTarget::Shared => Ok((game.root().to_path_buf(), Vec::new())), + AdoptTarget::VersionDir => Ok((game.version_dir(version_id), Vec::new())), + AdoptTarget::Custom(dir) => Ok((run_dir::resolve_custom_dir(dir)?, Vec::new())), } } @@ -272,53 +274,3 @@ fn resolve_name(name: Option<&str>, version_id: &str) -> String { .map(str::to_string) .unwrap_or_else(|| version_id.to_owned()) } - -/// 缺省运行目录必须**真的**落在启动器数据根之下(挡住「数据区里预先放了指向外面的目录联接」)。 -/// -/// 边界是 ``(`/.ULL/data`)而不是 `/instances`:后者本身可能就是链接, -/// 拿它当基准等于把「越界」当成「正常」。`` 自己是链接是用户自己的迁移手法(D6 的数据目录 -/// 迁移就是整棵树搬走),那种情况允许——判据是「最终落点仍在**数据根解析后的位置**之内」。 -/// -/// **已知边界**:这是检查那一刻的判据;检查与 `create_dir_all` 之间被人换掉组件(TOCTOU) -/// 本函数看不到——本命令不是并发/对抗入口。 -fn ensure_inside_data(launcher: &LauncherLayout, dir: &Path) -> Result<(), AdoptError> { - let data = launcher.data(); - let Some(data_real) = canonical_existing(&data) else { - // 连数据根都还不存在 ⇒ 不可能已经有指向外面的链接。 - return Ok(()); - }; - let Some(dir_real) = canonical_existing(dir) else { - return Err(AdoptError::TargetInvalid { - detail: format!("解析运行目录的真实位置失败:{}", dir.display()), - }); - }; - if !dir_real.starts_with(&data_real) { - return Err(AdoptError::TargetInvalid { - detail: format!( - "运行目录 {} 解析到 {},落在数据根 {} 之外:数据区里有指向外部的符号链接/目录联接,拒绝沿它写盘", - dir.display(), - dir_real.display(), - data_real.display() - ), - }); - } - Ok(()) -} - -/// 最近的**已存在**祖先的真实路径(目标本身可能还不存在)。 -fn canonical_existing(path: &Path) -> Option { - let mut probe = path.to_path_buf(); - loop { - if std::fs::symlink_metadata(&probe).is_ok() { - return probe.canonicalize().ok(); - } - if !probe.pop() { - return None; - } - } -} - -/// 「这个目录已经有人在用」= 存在且非空(空目录是上次失败的残留,可复用)。 -fn directory_occupied(path: &Path) -> bool { - std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_some()) -} diff --git a/crates/core/src/game/adopt/run_dir.rs b/crates/core/src/game/adopt/run_dir.rs new file mode 100644 index 0000000..bff5582 --- /dev/null +++ b/crates/core/src/game/adopt/run_dir.rs @@ -0,0 +1,327 @@ +//! 运行目录与**写盘边界**:缺省目录派生(含碰撞消歧)+ 数据区/配置写面的真实路径判据。 +//! +//! # 两条铁律(都来自复审 r1 的实测缺陷) +//! +//! 1. **两个实例不许悄悄共用同一个运行目录**(R1):展示名不重复不代表派生目录不重复 +//! (`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 配置文件。 +//! +//! # 判据的基准(为什么不拿 `/instances` 或 `<.ULL>/config` 当基准) +//! +//! 被保护的**边界自己是链接**时,拿它当基准等于把越界当正常。所以基准一律是**解析后的父级**: +//! 数据写面看 ``,配置写面看 `<.ULL>`、并额外排除游戏根。用户把整棵树迁到别的盘 +//! (`<.ULL>` 或 `` 本身是联接/符号链接)仍然允许——判据是落点,不是路径长得像不像。 +//! +//! # 已知边界 +//! +//! 判据是「检查那一刻」的快照:检查与 `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 crate::platform::layout::LauncherLayout; + +/// 派生目录撞车时最多顺延多少个后缀(`-2` … `-33`)。撞满即报错,不无限找。 +const MAX_SUFFIX: u32 = 32; + +/// 一个「已有实例已经占着的运行目录」。 +/// +/// 只收 `gameDir`(现役设置文件里的,或 M27 前档案的遗留 `game_dir`):`shared` / `isolated` +/// 档的运行目录是**派生**的(游戏根 / `versions/`),与缺省落点 `/instances/<名>` +/// 不可能相等,收进来只会制造噪音。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RunDirClaim { + /// 占着这个目录的实例 id。 + pub(super) instance_id: String, + /// 实例展示名(写进提示给人看)。 + pub(super) name: String, + /// 声明/绑定的运行目录(原样,未真实化)。 + pub(super) dir: PathBuf, + /// 比较用的键(真实化路径 + 大小写归一,见 [`dir_key`])。 + pub(super) key: String, +} + +/// 收集现有实例占着的运行目录(读 `config/instances/.json`;读不了就是错误,不猜)。 +/// +/// # Errors +/// +/// 实例设置文件读不出来 / JSON 坏:`AdoptError::Game`。 +pub(super) fn claims( + launcher: &LauncherLayout, + profiles: &ProfileList, +) -> Result, AdoptError> { + let mut out = Vec::new(); + for profile in &profiles.profiles { + let config = read_instance_config(launcher, profile.id.as_str())?; + let dir = config + .and_then(|config| config.game_dir) + .or_else(|| profile.game_dir.clone()); + let Some(dir) = dir.filter(|dir| !dir.as_os_str().is_empty()) else { + continue; + }; + out.push(RunDirClaim { + instance_id: profile.id.as_str().to_owned(), + name: profile.name.clone(), + key: dir_key(&dir), + dir, + }); + } + Ok(out) +} + +/// 缺省运行目录:`/instances/<实例目录名>`,撞车/占用时顺延后缀。 +/// +/// 判据顺序(三条都要过): +/// 1. 路径上的已有祖先解析后必须仍在数据根内([`ensure_inside_data`]); +/// 2. **不能**是已有实例占着的目录(按真实路径比较,[`dir_key`])——撞上就顺延; +/// 3. 目录必须不存在或为空(非空且无人认领 = 用户数据,拒绝而不是换目录)。 +/// +/// 返回派生目录 + 人读提示(顺延过就有一条,没顺延就是空)。 +/// +/// # Errors +/// +/// 数据区越界(`TargetInvalid`)、非空目录(`TargetNotEmpty`)、后缀撞满(`NoFreeRunDir`)、 +/// 读实例设置失败(`Game`)。 +pub(super) fn derive_instance_dir( + launcher: &LauncherLayout, + name: &str, + claims: &[RunDirClaim], +) -> Result<(PathBuf, Vec), AdoptError> { + let base = pack_dir::instance_dir(launcher, name); + let mut bumped: Vec = Vec::new(); + let mut attempt = base.clone(); + let mut suffix = 1; + loop { + ensure_inside_data(launcher, &attempt)?; + let key = dir_key(&attempt); + match claims.iter().find(|claim| claim.key == key) { + Some(claim) => { + bumped.push(claim.clone()); + suffix += 1; + if suffix > MAX_SUFFIX { + return Err(AdoptError::NoFreeRunDir { base }); + } + attempt = with_suffix(&base, suffix); + } + None if directory_occupied(&attempt) => { + // 非空且没有实例认领 ⇒ 用户自己的数据:不覆盖,也不偷偷换个目录把它晾在一边。 + return Err(AdoptError::TargetNotEmpty { path: attempt }); + } + None => { + let mut notes = Vec::new(); + if let Some(claim) = bumped.first() { + notes.push(format!( + "派生目录 {} 与已有实例 `{}`(id {})的运行目录 {} 是同一个物理目录,\ + 已改用 {} —— 两个实例不会共用同一个运行目录", + base.display(), + claim.name, + claim.instance_id, + claim.dir.display(), + attempt.display() + )); + } + return Ok((attempt, notes)); + } + } + } +} + +/// `-2` / `-3` …(只动最后一段)。 +fn with_suffix(base: &Path, suffix: u32) -> PathBuf { + let name = base + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + base.with_file_name(format!("{name}-{suffix}")) +} + +/// 缺省运行目录必须**真的**落在启动器数据根之下。 +/// +/// 边界是 ``(`/.ULL/data`)而不是 `/instances`:后者本身可能就是链接, +/// 拿它当基准等于把「越界」当成「正常」。 +/// +/// # Errors +/// +/// 解析后跑出数据根(`TargetInvalid`)。 +pub(super) fn ensure_inside_data(launcher: &LauncherLayout, dir: &Path) -> Result<(), AdoptError> { + let Some(data_real) = existing_real(&launcher.data()) else { + // 连数据根都还不存在 ⇒ 不可能已经有指向外面的链接。 + return Ok(()); + }; + if let Some(dir_real) = existing_real(dir) { + if !dir_real.starts_with(&data_real) { + return Err(AdoptError::TargetInvalid { + detail: format!( + "运行目录 {} 解析到 {},落在数据根 {} 之外:数据区里有指向外部的符号链接/目录联接,拒绝沿它写盘", + dir.display(), + dir_real.display(), + data_real.display() + ), + }); + } + } + 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 +/// +/// 相对路径或空路径:`TargetInvalid`。 +pub(super) fn resolve_custom_dir(dir: &Path) -> Result { + if dir.as_os_str().is_empty() { + return Err(AdoptError::TargetInvalid { + detail: "运行目录为空".to_owned(), + }); + } + if !dir.is_absolute() { + return Err(AdoptError::TargetInvalid { + detail: format!("--game-dir 需要绝对路径,收到 `{}`", dir.display()), + }); + } + Ok(dir.to_path_buf()) +} + +/// 目录是否「有人在用」(存在且非空)。空目录是上次失败的残留,可复用。 +pub(super) fn directory_occupied(path: &Path) -> bool { + std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_some()) +} + +/// **比较用**的真实路径键:解析链接 + 拼回尚不存在的尾段 + 统一分隔符 + 大小写归一。 +/// +/// 大小写归一只在大小写不敏感的平台做(Windows / 默认 macOS):`Foo` 与 `foo` 是同一个目录, +/// 不归一会让「两个实例共用目录」这一条在 Windows 上漏掉(复审 r1 的实测之一)。 +/// +/// **策略是保守的**:macOS 也可能是大小写敏感的 APFS 卷,那时 `Foo` 与 `foo` 本来是**不同**目录, +/// 归一后会判成碰撞 ⇒ 顺延 `-2`。代价是目录名可能多一个后缀,换来的是「绝不共用」这条不变量 +/// 在任何卷上都成立(判错的方向只能是「多一次顺延」,不能是「共用一个目录」)。 +/// +/// **目标可能还不存在**:这里不要求路径存在([`existing_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; + } + } +} diff --git a/crates/core/tests/adopt/collision_cases.rs b/crates/core/tests/adopt/collision_cases.rs new file mode 100644 index 0000000..6f49ec5 --- /dev/null +++ b/crates/core/tests/adopt/collision_cases.rs @@ -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 + ); +} diff --git a/crates/core/tests/adopt/fixture.rs b/crates/core/tests/adopt/fixture.rs index da07224..9a28d63 100644 --- a/crates/core/tests/adopt/fixture.rs +++ b/crates/core/tests/adopt/fixture.rs @@ -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 { - let mut out = BTreeMap::new(); - walk(dir, dir, &mut out); - out -} - -fn walk(root: &Path, dir: &Path, out: &mut BTreeMap) { - 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, - after: &BTreeMap, - what: &str, -) { - assert_eq!( - before.keys().collect::>(), - after.keys().collect::>(), - "{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 `:三个参数分开传,别拼成一整条(拼成一串会被 cmd 当语法错)。 + // `cmd /c mklink /J `:三个参数分开传,别拼成一整条(拼成一串会被 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)"); +} diff --git a/crates/core/tests/adopt/fixture_cases.rs b/crates/core/tests/adopt/fixture_cases.rs new file mode 100644 index 0000000..b915af1 --- /dev/null +++ b/crates/core/tests/adopt/fixture_cases.rs @@ -0,0 +1,141 @@ +//! 快照工具自身的回归:它必须抓得住「凭空多出一个空目录」(复审 r1 的 R5)。 +//! +//! 早先的快照只记文件:`scan` 或 `--dry-run` 若在外部树里建了一个空目录(例如 +//! `/versions//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::>(), + again.keys().collect::>(), + "删掉空目录后路径集合应回到原样" + ); +} + +/// 目录的 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()); + } +} diff --git a/crates/core/tests/adopt/main.rs b/crates/core/tests/adopt/main.rs index b318400..15ea73a 100644 --- a/crates/core/tests/adopt/main.rs +++ b/crates/core/tests/adopt/main.rs @@ -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; diff --git a/crates/core/tests/adopt/plan_cases.rs b/crates/core/tests/adopt/plan_cases.rs index 4062352..6261664 100644 --- a/crates/core/tests/adopt/plan_cases.rs +++ b/crates/core/tests/adopt/plan_cases.rs @@ -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 不该建配置目录(连空的也不行)" + ); } /// 不可登记的版本(缺父)⇒ 一条都不登记,问题清单原样带出。 diff --git a/crates/core/tests/adopt/register_cases.rs b/crates/core/tests/adopt/register_cases.rs index adcffd4..4d74e95 100644 --- a/crates/core/tests/adopt/register_cases.rs +++ b/crates/core/tests/adopt/register_cases.rs @@ -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; } diff --git a/crates/core/tests/adopt/scan_cases.rs b/crates/core/tests/adopt/scan_cases.rs index 21ef436..a8d8e96 100644 --- a/crates/core/tests/adopt/scan_cases.rs +++ b/crates/core/tests/adopt/scan_cases.rs @@ -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; } diff --git a/crates/core/tests/adopt/snapshot.rs b/crates/core/tests/adopt/snapshot.rs new file mode 100644 index 0000000..92afb5e --- /dev/null +++ b/crates/core/tests/adopt/snapshot.rs @@ -0,0 +1,180 @@ +//! 目录树**全节点**快照:目录 / 文件 / 链接各记自己的类型与适用 mtime。 +//! +//! # 为什么不是「只记文件」 +//! +//! 复审 r1 的 R5:早先版本遇到目录只递归、不记录,于是「登记过程凭空建了一个空目录」 +//! (例如错误地在 `/versions//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 { + /// 链接目标(原样,可能是相对路径;读不出来时记 ``)。 + target: PathBuf, + }, +} + +/// 一次快照:相对路径(根自身是 `.`)→ 节点。 +pub type Snapshot = BTreeMap; + +/// 拍一次全节点快照(目标不存在 = 空快照,见 [`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 { + 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("")); + 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::>(), + after.keys().collect::>(), + "{what}:路径集合(含目录/链接节点)变了" + ); + for (path, node) in before { + assert_eq!( + node, + after.get(path).expect("同一路径"), + "{what}:节点被改动 -> {path}" + ); + } +} diff --git a/crates/core/tests/adopt/surface_cases.rs b/crates/core/tests/adopt/surface_cases.rs new file mode 100644 index 0000000..9ab2d47 --- /dev/null +++ b/crates/core/tests/adopt/surface_cases.rs @@ -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/.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 { + let mut names: Vec = std::fs::read_dir(dir) + .expect("读目录") + .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned())) + .collect(); + names.sort(); + names +}