From 9f5c001387776465f30fc122cb21af27bd25a548 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 22:18:52 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(game):=20=E4=BB=8E=E5=85=B6=E4=BB=96?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=99=A8=E5=8F=AA=E8=AF=BB=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E5=B7=B2=E6=9C=89=E7=89=88=E6=9C=AC=EF=BC=88ull=20instances=20?= =?UTF-8?q?scan|adopt=EF=BC=89+=20=E7=99=BB=E8=AE=B0=E5=8F=AF=E4=B8=80?= =?UTF-8?q?=E5=B9=B6=E5=AE=9A=E9=9A=94=E7=A6=BB=E6=A1=A3=EF=BC=88M221?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 盘上已经铺好的版本(多半是 PCL/HMCL 装的)今天在 ULL 里看不见,用户只能重装一遍。这个 commit 把它们登记成实例,**只读外部、只写自己的配置**:不搬文件、不补清单、不改任何外部 文件的 mtime(versions/ libraries/ assets/ 一个字节都不写)。 core(`game::adopt`,新模块): - `scan` / `inspect`:只读扫描 + 逐项检查,坏 JSON(带行列号)/ 缺 id / id 与目录名不一致 / 缺父 / 成环 / 读不出来 / 越界 / 同名冲突一律**结构化**报错(稳定 `code`),不吞成空列表; `versions/` 不存在才是空结果,读不了就是错误; - 越界判据用 canonicalize 后的前缀比较,同时覆盖**符号链接、Windows 目录联接(junction)与 祖先链接**(junction 不需要管理员权限,是最常见的「把别人版本目录挂进来」);越界条目在 边界检查就停手,一个字节都不去读; - `plan`(零写盘,喂 dry-run 与后续 GUI 预检)/ `adopt`(plan + 只写自己的配置): **缺省运行目录落 ULL 数据区 `/instances/<实例目录名>`(custom 档),绝不沿用全局 `isolated`**——那条缺省的运行目录正是 `versions//`,会在他人的版本目录里长出 mods/saves/config;`shared` / `isolated` 必须显式选,且 `target_notes()` 给出后果话术; - 缺省运行目录已存在且非空 ⇒ 拒绝(不覆盖、不猜「合并进已有实例」);数据区里已有的祖先 若解析到数据根之外(符号链接/联接)⇒ 拒绝,不沿它写盘; - 登记与隔离档一次写清:`install/instance` 拆出 `register` 子模块,新增 `register_with(InstanceSettings)`;写盘顺序改成「先实例设置、后 profiles」(中断只留孤立 设置文件,不会留下按全局档启动的实例),profiles 写失败会撤掉刚写的设置。`register` 签名与行为不变(`register` = `register_with(inherit)`;同版本同名的幂等复用照旧)。 CLI: - `ull instances scan`(只读列出候选与原因)、`ull instances adopt [--name N] [--isolation shared|isolated|custom] [--game-dir DIR] [--dry-run]`;`--game-dir` 单独给即 custom,相对路径在解析期就报用法错误; - 人读/`--json`/help 三条出口齐备(`error.detail.problems` 逐条可分流),已有命令未改。 测试(真磁盘夹具,`cargo test -p unlucky-core --test adopt` 22 passed / `cargo test -p unlucky-cli --test adopt_test` 10 passed): - 一份「别的启动器铺好的」`.minecraft`(versions/libraries/assets/mods/saves/config + launcher_profiles.json),扫描与登记前后做「路径集合 + 字节数 + 内容哈希 + mtime」全等断言; - 正常 / 缺清单 / 坏 JSON / 缺父 / 成环 / id 不匹配 / 非法 id / 符号链接越界 / 根内符号链接 放行 / **junction 越界** / 数据区 junction 写盘边界 / 缺省非 isolated / shared 与 isolated 的话术 / 重名 / 重复 / 非空目录 / 空目录复用 / dry-run 零写盘; - CLI 侧验 `launch --dry-run` 的 `--gameDir` 指向选定运行目录(argv 已脱敏,比尾段; `data.game_dir` 全等); - 变异核对(只改实现、跑完即回滚):缺省档改 `VersionDir` ⇒ 红;边界判据摘掉 ⇒ 符号链接与 junction 两条红;写盘边界摘掉 ⇒ 数据区 junction 那条红;缺父当链终点 ⇒ 两条红。 界限:没有启动真实 Minecraft;GUI(presenter/app-iced)接线另派;D20 单游戏根模型未改 (不扫第二个 `.minecraft`)。 --- crates/cli/src/cli/help.rs | 37 +- crates/cli/src/cli/model.rs | 20 + crates/cli/src/cli/parse/instances.rs | 79 +++- crates/cli/src/commands/instances.rs | 4 + crates/cli/src/commands/instances/adopt.rs | 118 ++++++ .../src/commands/instances/adopt_shared.rs | 72 ++++ crates/cli/src/commands/instances/scan.rs | 91 +++++ crates/cli/src/commands/mod.rs | 4 + crates/cli/tests/adopt_test.rs | 315 +++++++++++++++ crates/cli/tests/adopt_test/cli_surface.rs | 102 +++++ crates/cli/tests/adopt_test/fixture.rs | 140 +++++++ crates/core/src/game/adopt.rs | 54 +++ crates/core/src/game/adopt/candidate.rs | 276 +++++++++++++ crates/core/src/game/adopt/error.rs | 143 +++++++ crates/core/src/game/adopt/inspect.rs | 353 +++++++++++++++++ crates/core/src/game/adopt/paths.rs | 84 ++++ crates/core/src/game/adopt/plan.rs | 324 ++++++++++++++++ crates/core/src/game/install/instance.rs | 74 +--- .../src/game/install/instance/register.rs | 150 ++++++++ crates/core/src/game/mod.rs | 9 + crates/core/tests/adopt/fixture.rs | 292 ++++++++++++++ crates/core/tests/adopt/main.rs | 16 + crates/core/tests/adopt/plan_cases.rs | 112 ++++++ crates/core/tests/adopt/register_cases.rs | 300 +++++++++++++++ crates/core/tests/adopt/scan_cases.rs | 364 ++++++++++++++++++ 25 files changed, 3460 insertions(+), 73 deletions(-) create mode 100644 crates/cli/src/commands/instances/adopt.rs create mode 100644 crates/cli/src/commands/instances/adopt_shared.rs create mode 100644 crates/cli/src/commands/instances/scan.rs create mode 100644 crates/cli/tests/adopt_test.rs create mode 100644 crates/cli/tests/adopt_test/cli_surface.rs create mode 100644 crates/cli/tests/adopt_test/fixture.rs create mode 100644 crates/core/src/game/adopt.rs create mode 100644 crates/core/src/game/adopt/candidate.rs create mode 100644 crates/core/src/game/adopt/error.rs create mode 100644 crates/core/src/game/adopt/inspect.rs create mode 100644 crates/core/src/game/adopt/paths.rs create mode 100644 crates/core/src/game/adopt/plan.rs create mode 100644 crates/core/src/game/install/instance/register.rs create mode 100644 crates/core/tests/adopt/fixture.rs create mode 100644 crates/core/tests/adopt/main.rs create mode 100644 crates/core/tests/adopt/plan_cases.rs create mode 100644 crates/core/tests/adopt/register_cases.rs create mode 100644 crates/core/tests/adopt/scan_cases.rs diff --git a/crates/cli/src/cli/help.rs b/crates/cli/src/cli/help.rs index b2c211e..15b84ea 100644 --- a/crates/cli/src/cli/help.rs +++ b/crates/cli/src/cli/help.rs @@ -17,6 +17,10 @@ pub const fn general() -> &'static str { instances launch-args [--jvm T] [--game T] [--memory MB] [--java P] [--backend default|opengl|vulkan] [--clear] 读/写实例级启动参数(不带选项 = 只读;--clear = 全部回跟随全局) + instances scan 列出 versions/ 里已铺好的版本(含别的启动器装的):能不能登记 + 原因 + instances adopt [--name N] [--isolation shared|isolated|custom] [--game-dir DIR] + [--dry-run] 把已铺好的版本登记成实例(只读外部、不搬文件;缺省运行目录 + 落 ULL 数据区的独立目录;--dry-run 只检查不写盘) install [--loader X] [--loader-version V] [--name N] [--dry-run] 下载并安装版本本体(--loader fabric|quilt|neoforge|forge 时再叠一层加载器)→ 登记实例;--dry-run 只出计划 @@ -91,7 +95,38 @@ pub fn topic(name: &str) -> Option<&'static str> { 每一项都是「实例 → 全局 → 默认」的独立回落,只填一项不会顶掉别的项。 值以 `--` 开头时(多数游戏参数都是)写成 `--game=--fullscreen`:单独一个 `--fullscreen` 会被当成下一个选项(缺少取值),这是全项目 CLI 的既有解析口径。 - --json 输出 {\"data\":{\"launch\":{…实例层…},\"effective\":{…回落后的生效值…}, …}}", + --json 输出 {\"data\":{\"launch\":{…实例层…},\"effective\":{…回落后的生效值…}, …}} + +%prog% instances scan + +列出当前游戏根 versions/ 里已经铺好的版本(可能是别的启动器装的),逐项给结论: + ✓ 可登记 / 已登记(附父链);✗ 不可登记 + 每条原因(带稳定 code:manifest_missing / + manifest_invalid_json / missing_parent / circular_inheritance / outside_game_root …)。 +只读:不写文件、不改 mtime。versions/ 不存在 = 空结果;versions/ 读不了 = 报错(不当空列表)。 + --json 输出 {\"data\":{\"game_root\":…,\"versions_dir\":…,\"counts\":{…},\"candidates\":[…]}} + 每个 candidate 带 {\"version_id\",\"jar_present\",\"inherits\",\"registered\",\"adoptable\",\"problems\"}。 + +%prog% instances adopt [--name N] [--isolation shared|isolated|custom] + [--game-dir DIR] [--dry-run] + +把 versions// 登记成实例:只读外部、只写自己的配置——不搬文件、不补清单、不改任何 +外部文件的 mtime(versions/ libraries/ assets/ 一个字节都不写)。不读任何启动器的账户凭据。 + 运行目录(--gameDir)三档,缺省不沿用全局 isolated(那会把 mods/saves/config 造进 + 别人的 versions//,那是别的启动器的版本目录): + 不给 --isolation → <数据根>/.ULL/data/instances/<实例目录名>(新建空目录,隔离档写 custom) + --isolation shared → 别人的游戏根(共用其 mods/saves/config;明确选择后才生效) + --isolation isolated → versions//(第一次启动会在这里新增玩家数据目录) + --isolation custom --game-dir DIR → 指定目录(DIR 必须是绝对路径;不新建、不改动其中内容) + 拒绝(退出码 1,原因逐条进 error.detail.problems): + · 清单坏了 / 读不出来 / 缺 id / id 与目录名不一致 + · 缺父版本 / 父链成环(说清缺哪个、环是什么) + · 条目解析到游戏根之外(符号链接/目录联接/祖先链接) + · 展示名已被别的实例占用;这个版本已经同名登记过(重复执行 = 无副作用失败) + · 缺省运行目录已存在且非空(空目录可复用;不猜「合并进已有实例」) +--dry-run:只检查并打印将要用的隔离档/运行目录,不写任何文件、不建目录。 + --json 输出 {\"data\":{\"dry_run\":…,\"instance\":{\"id\",\"name\",\"isolation\",\"run_dir\", + \"run_dir_source\",\"config_path\",\"created\"},\"notes\":[…]}}。 +", ), "pack" => Some( "\ diff --git a/crates/cli/src/cli/model.rs b/crates/cli/src/cli/model.rs index 3bdf2f4..c5af1c8 100644 --- a/crates/cli/src/cli/model.rs +++ b/crates/cli/src/cli/model.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; +use unlucky_core::game::AdoptTarget; + /// 全局选项(所有命令共享)。 #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Globals { @@ -26,6 +28,10 @@ pub enum Command { Help(Option), /// `instances list`。 InstancesList, + /// `instances scan`:列出当前游戏根里可登记 / 不可登记的外部版本(只读,M221)。 + InstancesScan, + /// `instances adopt [--name N] [--isolation ...] [--game-dir DIR] [--dry-run]`。 + InstancesAdopt(InstancesAdoptArgs), /// `instances launch-args [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。 InstancesLaunchArgs(InstanceLaunchArgs), /// `install [--loader X] [--name N]`。 @@ -203,6 +209,20 @@ pub struct DiagArgs { pub tail: usize, } +/// `instances adopt` 的参数(M221:把盘上已有的版本登记成实例,只读外部、只写自己的配置)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstancesAdoptArgs { + /// 要接入的版本 id(= `versions//`)。 + pub version_id: String, + /// 展示名(缺省 = 版本 id)。 + pub name: Option, + /// 运行目录选择:缺省 [`AdoptTarget::InstanceDir`](ULL 数据区新目录), + /// `shared` / `isolated` / `custom` 都必须显式给。 + pub target: AdoptTarget, + /// 只检查、不写任何文件(`--dry-run`)。 + pub dry_run: bool, +} + /// `instances launch-args` 的参数(M157:实例级启动参数)。 #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct InstanceLaunchArgs { diff --git a/crates/cli/src/cli/parse/instances.rs b/crates/cli/src/cli/parse/instances.rs index 9b4b9e9..b9a42d1 100644 --- a/crates/cli/src/cli/parse/instances.rs +++ b/crates/cli/src/cli/parse/instances.rs @@ -1,9 +1,13 @@ -//! `instances` 的两个子命令解析(`list` / `launch-args`)。 +//! `instances` 的子命令解析(`list` / `launch-args` / `scan` / `adopt`)。 //! //! 从 `parse.rs` 拆出来只为守 400 行硬线(M157 的 `launch-args` 让那边到 415 行); -//! 纯搬移,语义未变。 +//! 纯搬移,语义未变。M221 在这里加了 `scan` 与 `adopt` 两个只读/登记入口。 -use super::super::model::InstanceLaunchArgs; +use std::path::PathBuf; + +use unlucky_core::game::AdoptTarget; + +use super::super::model::{InstanceLaunchArgs, InstancesAdoptArgs}; use super::*; pub(super) fn parse_instances(tail: &[String]) -> Result { @@ -12,10 +16,16 @@ pub(super) fn parse_instances(tail: &[String]) -> Result { Some("launch-args") => { return parse_instances_launch_args(tail.get(1..).unwrap_or_default()) } + Some("scan") => { + let parsed = parse_flags(tail.get(1..).unwrap_or_default(), &[], &[])?; + ensure_no_positionals(&parsed, "instances scan")?; + return Ok(Command::InstancesScan); + } + Some("adopt") => return parse_instances_adopt(tail.get(1..).unwrap_or_default()), Some(other) => { return Err(CliError::usage(format!( - "instances 的子命令只有 list / launch-args,收到 `{other}`" - ))); + "instances 的子命令只有 list / launch-args / scan / adopt,收到 `{other}`" + ))) } }; let parsed = parse_flags(args, &[], &[])?; @@ -43,3 +53,62 @@ fn parse_instances_launch_args(tail: &[String]) -> Result { clear: parsed.has("clear"), })) } + +/// `instances adopt [--name N] [--isolation shared|isolated|custom] [--game-dir DIR] [--dry-run]`。 +/// +/// 运行目录的词表只在这里翻译一次([`AdoptTarget::parse`] 是唯一判据): +/// - 什么都不给 ⇒ [`AdoptTarget::InstanceDir`](ULL 数据区新目录,**不沿用全局 `isolated`**); +/// - `--isolation custom` 必须配 `--game-dir`; +/// - `--isolation shared|isolated` 不许再配 `--game-dir`(那两个档的运行目录是派生的)。 +fn parse_instances_adopt(tail: &[String]) -> Result { + let parsed = parse_flags(tail, &["name", "isolation", "game-dir"], &["dry-run"])?; + let version_id = require_single_positional(&parsed, "instances adopt ")?; + let isolation = parsed.value("isolation"); + let game_dir = match parsed.value("game-dir") { + Some(raw) => { + let dir = PathBuf::from(&raw); + // 用法错误在解析期就报(和 `--data-dir` 同一口径),别把「参数写错」拖成命令期失败。 + if !dir.is_absolute() { + return Err(CliError::usage(format!( + "--game-dir 需要绝对路径,收到 `{raw}`" + ))); + } + Some(dir) + } + None => None, + }; + let target = match isolation.as_deref() { + None => match game_dir { + // `--game-dir` 单独给 = 就是 custom(少写一个选项,不制造第二种说法)。 + Some(dir) => AdoptTarget::Custom(dir), + // 缺省**只能**来自 core 的 `AdoptTarget::default()`:这里再写一遍 `InstanceDir` + // 就等于有了第二处真相源,改缺省时会漏一处。 + None => AdoptTarget::default(), + }, + Some("custom") => { + let dir = game_dir.ok_or_else(|| { + CliError::usage("--isolation custom 需要配 --game-dir (绝对路径)") + })?; + AdoptTarget::Custom(dir) + } + Some(other) => { + if game_dir.is_some() { + return Err(CliError::usage( + "--game-dir 只配 --isolation custom(shared / isolated 的运行目录是派生的)", + )); + } + AdoptTarget::parse(other, None).ok_or_else(|| { + CliError::usage(format!( + "--isolation 只有 shared|isolated|custom,收到 `{other}`" + )) + .with_hint("不给 --isolation = 落 ULL 数据区的独立运行目录(推荐)") + })? + } + }; + Ok(Command::InstancesAdopt(InstancesAdoptArgs { + version_id, + name: parsed.value("name"), + target, + dry_run: parsed.has("dry-run"), + })) +} diff --git a/crates/cli/src/commands/instances.rs b/crates/cli/src/commands/instances.rs index 857ea95..ad74862 100644 --- a/crates/cli/src/commands/instances.rs +++ b/crates/cli/src/commands/instances.rs @@ -144,6 +144,10 @@ fn loader_json(version_id: &str) -> Value { }) } +pub mod adopt; +mod adopt_shared; +pub mod scan; + #[cfg(test)] mod tests; diff --git a/crates/cli/src/commands/instances/adopt.rs b/crates/cli/src/commands/instances/adopt.rs new file mode 100644 index 0000000..c6fec71 --- /dev/null +++ b/crates/cli/src/commands/instances/adopt.rs @@ -0,0 +1,118 @@ +//! `ull instances adopt`:把盘上已有的版本登记成实例(M221)。 +//! +//! **只读外部、只写自己**:写盘面只有 `<数据根>/.ULL/config/` 下的两份配置(+ 选中缺省运行目录时 +//! 建那个空目录),`versions/` `libraries/` `assets/` 一个字节都不写。判据、落点与话术全在 +//! [`unlucky_core::game::adopt`];本模块只做「参数 → core 调用 → 人读/JSON 两条输出」。 +//! +//! `--dry-run` 走 `adopt::plan`(零写盘),所以「先看一眼」不会改任何东西,也不会建目录。 + +use serde_json::{json, Value}; +use unlucky_core::game::adopt::{self, AdoptRequest}; +use unlucky_core::game::GameLayout; +use unlucky_core::launch::time::{format_rfc3339_millis, now}; +use unlucky_core::platform::layout::LauncherLayout; + +use crate::cli::model::InstancesAdoptArgs; +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +use super::adopt_shared::{candidate_json, map_error}; + +/// `ull instances adopt [--name N] [--isolation …] [--game-dir DIR] [--dry-run]`。 +/// +/// # Errors +/// +/// 版本不在盘上 / 候选不合格 / 名字冲突 / 运行目录不可用 / 读配置失败,全部由 +/// [`unlucky_core::game::adopt::AdoptError`] 映射成稳定错误码(`adopt_*`)。 +pub fn run(ctx: &Ctx, args: &InstancesAdoptArgs) -> Result { + let request = AdoptRequest { + version_id: args.version_id.clone(), + name: args.name.clone(), + target: args.target.clone(), + created_at: Some(format_rfc3339_millis(now())), + }; + let (game, launcher) = (ctx.game(), ctx.layout()); + if args.dry_run { + return dry_run(&game, &launcher, &request); + } + let outcome = adopt::adopt(&game, &launcher, &request).map_err(map_error)?; + // 人读行先拼(只借用),再把字段移进 JSON(两条出口的内容同源、顺序不影响事实)。 + let mut human = vec![format!( + "已登记实例 `{}`(id {})→ 版本 {}", + outcome.name, outcome.instance_id, outcome.version_id + )]; + human.push(format!( + " 隔离档 {}(运行目录来源 {}),运行目录 {}", + outcome.isolation.as_str(), + outcome.target.as_str(), + outcome.run_dir.display() + )); + human.push(format!(" 实例设置 {}", outcome.config_path.display())); + if outcome.dir_created { + human.push(" 已新建该运行目录(空目录;玩家数据由游戏自己往里写)".to_string()); + } + human.push( + " 只写了本启动器的配置:versions/ libraries/ assets/ 一个字节都没动(不搬文件)" + .to_string(), + ); + human.extend(outcome.notes.iter().map(|note| format!(" 提示:{note}"))); + let version_id = outcome.version_id.clone(); + let data = json!({ + "dry_run": false, + "version_id": version_id, + "instance": json!({ + "id": outcome.instance_id, + "name": outcome.name, + "version_id": version_id, + "isolation": outcome.isolation.as_str(), + "run_dir": outcome.run_dir.display().to_string(), + "run_dir_source": outcome.target.as_str(), + "config_path": outcome.config_path.display().to_string(), + "created": outcome.created, + "dir_created": outcome.dir_created, + }), + "notes": outcome.notes, + }); + Ok(Report::new(data, human)) +} + +/// `--dry-run`:只算落点与判据,不写盘(连缺省运行目录都不建)。 +fn dry_run( + game: &GameLayout, + launcher: &LauncherLayout, + request: &AdoptRequest, +) -> Result { + let plan = adopt::plan(game, launcher, request).map_err(map_error)?; + let mut human = vec![format!( + "检查通过:版本 {} 可登记(--dry-run:没有写任何文件)", + plan.candidate.version_id + )]; + human.push(format!( + " 实例名 {},隔离档 {}(运行目录来源 {})", + plan.name, + plan.isolation.as_str(), + plan.target.as_str() + )); + human.push(format!(" 运行目录 {}", plan.run_dir.display())); + human.extend(plan.notes.iter().map(|note| format!(" 提示:{note}"))); + let version_id = plan.candidate.version_id.clone(); + let data = json!({ + "dry_run": true, + "version_id": version_id, + "candidate": candidate_json(&plan.candidate), + "instance": json!({ + "id": Value::Null, + "name": plan.name, + "version_id": version_id, + "isolation": plan.isolation.as_str(), + "run_dir": plan.run_dir.display().to_string(), + "run_dir_source": plan.target.as_str(), + "config_path": Value::Null, + "created": false, + "dir_created": false, + }), + "notes": plan.notes, + }); + Ok(Report::new(data, human)) +} diff --git a/crates/cli/src/commands/instances/adopt_shared.rs b/crates/cli/src/commands/instances/adopt_shared.rs new file mode 100644 index 0000000..1e23f48 --- /dev/null +++ b/crates/cli/src/commands/instances/adopt_shared.rs @@ -0,0 +1,72 @@ +//! `instances scan` / `instances adopt` 共用的 JSON 与错误投影。 +//! +//! 两条命令输出同一种候选形状(`candidate` 对象),错误也走同一份映射——分两份写迟早漂。 + +use serde_json::{json, Value}; +use unlucky_core::game::adopt::{AdoptCandidate, AdoptError, AdoptProblem}; + +use crate::error::CliError; + +/// 候选 → `--json` 里的 `candidate` 对象。 +pub(super) fn candidate_json(candidate: &AdoptCandidate) -> Value { + json!({ + "version_id": candidate.version_id, + "version_dir": candidate.version_dir.display().to_string(), + "manifest_path": candidate.manifest_path.display().to_string(), + "jar_present": candidate.jar_present, + "manifest_id": candidate.manifest_id, + "inherits": candidate.inherits, + "registered": candidate.registered.iter().map(|item| json!({ + "id": item.id, + "name": item.name, + })).collect::>(), + "adoptable": candidate.adoptable(), + "problems": problems_json(&candidate.problems), + }) +} + +/// 问题清单 → 结构化数组(`code` 稳定、`message` 人读)。 +pub(super) fn problems_json(problems: &[AdoptProblem]) -> Value { + Value::Array( + problems + .iter() + .map(|problem| { + json!({ + "code": problem.code(), + "message": problem.to_string(), + }) + }) + .collect(), + ) +} + +/// `AdoptError` → CLI 错误:稳定 code + 逐条问题进 `detail`(脚本不必解析散文)。 +pub(super) fn map_error(error: AdoptError) -> CliError { + let code = error.code(); + let message = error.to_string(); + let cli = CliError::user(code, message); + let hint = match &error { + AdoptError::DuplicateName { .. } => { + Some("换个展示名:%prog% instances adopt --name <新名字>") + } + AdoptError::NotAdoptable { .. } => Some( + "这些版本要么清单坏了、要么缺父版本、要么越界:先在原来的启动器里把它修好,\ + 或 `%prog% install ` 让本启动器自己装一份", + ), + AdoptError::AlreadyRegistered { .. } => { + Some("用 `%prog% instances list` 看已有实例;要第二个实例请换 --name") + } + AdoptError::TargetNotEmpty { .. } => { + Some("换一个名字(缺省目录按名字派生),或用 --isolation custom --game-dir <空目录>") + } + AdoptError::UnknownVersion { .. } => Some("先看有哪些可用:%prog% instances scan"), + _ => None, + }; + let cli = match hint { + Some(hint) => cli.with_hint(hint), + None => cli, + }; + cli.with_detail(json!({ + "problems": problems_json(error.problems()), + })) +} diff --git a/crates/cli/src/commands/instances/scan.rs b/crates/cli/src/commands/instances/scan.rs new file mode 100644 index 0000000..dba0cf1 --- /dev/null +++ b/crates/cli/src/commands/instances/scan.rs @@ -0,0 +1,91 @@ +//! `ull instances scan`:列出当前游戏根 `versions/` 里**已经铺好**的版本,逐项说清能不能登记(M221)。 +//! +//! 只读:不写任何文件、不改任何 mtime。用途是「我盘上已经有别的启动器装好的版本,看看 ULL 认不认」, +//! 以及 `instances adopt` 的前置检查(同一套判据,见 [`unlucky_core::game::adopt::scan`])。 + +use serde_json::json; +use unlucky_core::game::adopt; + +use crate::context::Ctx; +use crate::error::CliError; +use crate::output::Report; + +use super::adopt_shared::candidate_json; + +/// `ull instances scan`。 +/// +/// # Errors +/// +/// `versions/` 或 `profiles.json` 读不了时返回用户错误(**不降级成空列表**)。 +pub fn run(ctx: &Ctx) -> Result { + let scan = adopt::scan(&ctx.game(), &ctx.layout()).map_err(super::adopt_shared::map_error)?; + let ready = scan + .candidates + .iter() + .filter(|candidate| candidate.adoptable()) + .count(); + let registered = scan + .candidates + .iter() + .filter(|candidate| candidate.is_registered()) + .count(); + + let candidates: Vec = scan.candidates.iter().map(candidate_json).collect(); + let data = json!({ + "game_root": ctx.data.game_home.display().to_string(), + "versions_dir": scan.versions_dir.display().to_string(), + "counts": { + "total": scan.candidates.len(), + "adoptable": ready, + "registered": registered, + }, + "candidates": candidates, + }); + + let mut human = vec![format!( + "游戏根 {}:versions/ 里 {} 个版本,{} 个可登记", + scan.versions_dir.display(), + scan.candidates.len(), + ready + )]; + if scan.candidates.is_empty() { + human.push( + " (空)这个游戏根下还没有任何版本;`%prog% install ` 可以自己装一份" + .to_string(), + ); + } + for candidate in &scan.candidates { + if candidate.adoptable() { + let state = if candidate.is_registered() { + "已登记" + } else { + "可登记" + }; + human.push(format!( + " ✓ {} [{state}]{}", + candidate.version_id, + chain_suffix(candidate) + )); + } else { + human.push(format!(" ✗ {}", candidate.version_id)); + for problem in &candidate.problems { + human.push(format!(" 原因[{0}] {problem}", problem.code())); + } + } + } + if ready > 0 { + human.push( + "登记一个:%prog% instances adopt (缺省落 ULL 数据区的独立运行目录)" + .to_string(), + ); + } + Ok(Report::new(data, human)) +} + +/// 人读行尾的父链提示(`1.12.2-forge-…` 后面标出它继承的是谁)。 +fn chain_suffix(candidate: &unlucky_core::game::AdoptCandidate) -> String { + match candidate.inherits.len() { + 0 | 1 => String::new(), + _ => format!(" 继承 {}", candidate.inherits[1..].join(" -> ")), + } +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 4adeb9a..960f5f8 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -37,6 +37,10 @@ pub fn run(invocation: &Invocation) -> Result { Command::InstancesLaunchArgs(args) => { with_ctx(&invocation.globals, |ctx| instance_launch::run(ctx, args)) } + Command::InstancesScan => with_ctx(&invocation.globals, instances::scan::run), + Command::InstancesAdopt(args) => { + with_ctx(&invocation.globals, |ctx| instances::adopt::run(ctx, args)) + } Command::Install(args) => with_ctx(&invocation.globals, |ctx| install::run(ctx, args)), Command::PackImport(args) => { with_ctx_outcome(&invocation.globals, |ctx| pack::run(ctx, args)) diff --git a/crates/cli/tests/adopt_test.rs b/crates/cli/tests/adopt_test.rs new file mode 100644 index 0000000..934ef5f --- /dev/null +++ b/crates/cli/tests/adopt_test.rs @@ -0,0 +1,315 @@ +//! M221 CLI 端到端:`ull instances scan` / `ull instances adopt` 的真实入口(解析 → 执行 → 两条输出)。 +//! +//! 用的都是**真磁盘**数据根(`support::TempDir`)+ 一份「别的启动器铺好的」`.minecraft`。 +//! 每条用例都断言外部文件哈希/mtime 不变(`fixture::assert_unchanged`), +//! 因为「只登记不搬文件」是本 mission 的核心承诺。 + +#[path = "adopt_test/cli_surface.rs"] +mod cli_surface; +#[path = "adopt_test/fixture.rs"] +mod fixture; +#[path = "support/mod.rs"] +mod support; + +use std::path::Path; + +use support::{parse_json, run_cli, TempDir}; + +use crate::fixture::{assert_unchanged, foreign_data_root, snapshot}; + +/// 缺省登记:落 ULL 数据区的独立目录,隔离档 `custom`,**不沿用全局 `isolated`**。 +/// +/// 抓得住: +/// - 缺省沿用全局 `isolated`(运行目录会变成 `versions/1.20.1`,第二条断言会红)—— +/// 这是本 mission 点名的最容易漏的一条; +/// - 命令只登记不输出(`--json` 缺 `instance`); +/// - 登记过程写了外部 `.minecraft`(哈希断言)。 +/// +/// 抓不住:GUI 是否也能触发同一条路(GUI 接线是后续 mission)。 +#[test] +fn adopt_without_isolation_uses_a_separate_run_dir_and_keeps_global_isolated_out_of_it() { + let dir = TempDir::new("adopt-default"); + let root = dir.path(); + foreign_data_root(root); + // 全局档显式写成 isolated(D20 的缺省):登记**不许**继承它去踩别人的版本目录。 + set_global_isolated(&dir); + let minecraft = root.join(".minecraft"); + let before = snapshot(&minecraft); + + let (code, stdout, stderr) = run_cli(&[ + "instances", + "adopt", + "1.20.1", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let after = snapshot(&minecraft); + assert_unchanged(&before, &after, "adopt 之后"); + + let value = parse_json(&stdout); + let instance = &value["data"]["instance"]; + assert_eq!(instance["isolation"], "custom"); + assert_eq!(instance["run_dir_source"], "instance_dir"); + let run_dir = instance["run_dir"].as_str().expect("run_dir"); + assert!( + run_dir.contains("instances"), + "缺省运行目录应落 ULL 数据区:{run_dir}" + ); + assert!( + !Path::new(run_dir).starts_with(&minecraft), + "缺省运行目录不许落进别人的 .minecraft:{run_dir}" + ); + assert!( + !run_dir.ends_with("versions/1.20.1") && !run_dir.ends_with("versions\\1.20.1"), + "不许沿用全局 isolated 的 versions/:{run_dir}" + ); + assert_eq!(instance["created"], true); + + // 再走一次 `instances list`:档案真的进去了(不是只写了实例文件)。 + let (code, stdout, _) = run_cli(&["instances", "list", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0); + let listed = parse_json(&stdout); + assert_eq!(listed["data"]["instances"][0]["version_id"], "1.20.1"); + assert_eq!(listed["data"]["instances"][0]["version_present"], true); +} + +/// 登记之后 `launch --dry-run` 的 `--gameDir` 就是选定的运行目录(本 mission 的功能落点)。 +/// +/// 抓得住:登记把隔离档写错(`inherit`)⇒ 启动派生坚持全局 `isolated`,`game_dir` 变成 +/// `versions/1.20.1`(第二条断言会红);`gameDir` 没进启动计划。 +/// 抓不住:真进程是否真的用这个 CWD(要真跑游戏,本 mission 不跑)。 +#[test] +fn the_launch_plan_points_game_dir_at_the_chosen_run_dir() { + let dir = TempDir::new("adopt-launch"); + let root = dir.path(); + foreign_data_root(root); + set_global_isolated(&dir); + + let (code, stdout, stderr) = run_cli(&[ + "instances", + "adopt", + "1.20.1", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let adopted = parse_json(&stdout); + let run_dir = adopted["data"]["instance"]["run_dir"] + .as_str() + .expect("run_dir") + .to_string(); + let instance_id = adopted["data"]["instance"]["id"] + .as_str() + .expect("instance id") + .to_string(); + + let dir_arg = dir.arg(); + let args = [ + "launch", + instance_id.as_str(), + "--dry-run", + "--json", + "--data-dir", + dir_arg.as_str(), + ]; + let (code, stdout, stderr) = run_cli(&args); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let plan = parse_json(&stdout); + assert_eq!(plan["data"]["game_dir"], run_dir.as_str()); + assert_eq!(plan["data"]["isolation"], "custom"); + assert_eq!(plan["data"]["isolation_source"], "instance"); + let command = plan["data"]["argv"] + .as_array() + .expect("启动计划里应有 argv"); + let game_dir_flag = command + .windows(2) + .find(|pair| pair[0].as_str() == Some("--gameDir")) + .map(|pair| pair[1].as_str().unwrap_or_default().to_string()) + .expect("命令行里应有 --gameDir"); + // `argv` 是**脱敏后**的(主目录 → ``,见 `core::launch::redact`),所以比尾段而不是全等; + // 全等的那条断言在 `data.game_dir` 上(那是未脱敏的权威值)。 + let tail = format!( + "instances/{}", + Path::new(&run_dir) + .file_name() + .expect("运行目录名") + .to_string_lossy() + ); + assert!( + game_dir_flag.ends_with(&tail) || game_dir_flag.ends_with(&tail.replace('/', "\\")), + "--gameDir 必须指向选定的运行目录:{game_dir_flag} 应以 {tail} 结尾" + ); +} + +/// `--isolation shared`:明确选共享 ⇒ 运行目录 = 别人的游戏根,**人读输出必须说出后果**。 +/// +/// 抓得住:shared 档没有告知(stdout 里没有「共用」)、shared 被写成 custom + 自己的目录、 +/// 登记往游戏根写文件(哈希断言)。 +/// 抓不住:用户是否真的看见这句(UI 验收另说)。 +#[test] +fn shared_isolation_states_the_consequence_in_human_output() { + let dir = TempDir::new("adopt-shared"); + let root = dir.path(); + foreign_data_root(root); + let minecraft = root.join(".minecraft"); + let before = snapshot(&minecraft); + + let (code, stdout, stderr) = run_cli(&[ + "instances", + "adopt", + "1.20.1", + "--isolation", + "shared", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let after = snapshot(&minecraft); + assert_unchanged(&before, &after, "shared 登记之后"); + assert!( + stdout.contains("共用"), + "必须说出「共用别人的 .minecraft」:{stdout}" + ); + assert!(stdout.contains("一个字节都没动"), "{stdout}"); +} + +/// `--dry-run`:只检查不写盘(连配置目录都不建)。 +/// +/// 抓得住:`--dry-run` 真的写了档案或建了运行目录(快照会红)。 +/// 抓不住:`--dry-run` 有没有顺带读了不该读的东西。 +#[test] +fn dry_run_writes_nothing() { + let dir = TempDir::new("adopt-dry"); + let root = dir.path(); + foreign_data_root(root); + let before = snapshot(root); + + let (code, stdout, stderr) = run_cli(&[ + "instances", + "adopt", + "1.20.1", + "--dry-run", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let after = snapshot(root); + assert_unchanged(&before, &after, "--dry-run 之后"); + assert!(stdout.contains("检查通过"), "{stdout}"); + assert!(stdout.contains("没有写任何文件"), "{stdout}"); +} + +/// 坏的版本(缺父)⇒ 退出码 1,`--json` 里带**逐条**问题码。 +/// +/// 抓得住:不合格版本被登记成功(退出码变 0)、问题码被折叠成一句散文(`detail.problems` 缺项)。 +/// 抓不住:用户在原来的启动器里修好之后重试(自然覆盖)。 +#[test] +fn not_adoptable_version_exits_one_with_structured_problems() { + let dir = TempDir::new("adopt-refuse"); + let root = dir.path(); + foreign_data_root(root); + let before = snapshot(root); + + let (code, stdout, _) = run_cli(&[ + "instances", + "adopt", + "1.20.1-orphan", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 1, "{stdout}"); + let value = parse_json(&stdout); + assert_eq!(value["ok"], false); + assert_eq!(value["error"]["code"], "adopt_not_adoptable"); + assert_eq!( + value["error"]["detail"]["problems"][0]["code"], + "missing_parent" + ); + let after = snapshot(root); + assert_unchanged(&before, &after, "失败之后"); +} + +/// 重复登记 ⇒ 退出码 1 + 无副作用(档案逐字节不变)。 +/// +/// 抓得住:重复登记当成功(退出码 0)或失败时改了档案。 +/// 抓不住:并发两次登记(没有锁;这不是并发入口)。 +#[test] +fn repeating_the_same_adopt_exits_one_without_side_effects() { + let dir = TempDir::new("adopt-repeat"); + let root = dir.path(); + foreign_data_root(root); + let (code, _, stderr) = run_cli(&["instances", "adopt", "1.20.1", "--data-dir", &dir.arg()]); + assert_eq!(code, 0, "{stderr}"); + let config = root.join(".ULL/config"); + let before = snapshot(&config); + + let (code, stdout, _) = run_cli(&[ + "instances", + "adopt", + "1.20.1", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 1, "{stdout}"); + assert_eq!( + parse_json(&stdout)["error"]["code"], + "adopt_already_registered" + ); + let after = snapshot(&config); + assert_unchanged(&before, &after, "重复登记失败之后"); +} + +/// `instances scan`:只读列出候选,坏版本逐条给原因。 +/// +/// 抓得住:扫描变成空列表(那是本 mission 点名不许的行为)、坏版本被静默跳过、 +/// 扫描写了文件。 +/// 抓不住:真实 `.minecraft` 里那些平台特有的怪目录(夹具只覆盖已知形状)。 +#[test] +fn scan_lists_candidates_and_reasons_without_writing() { + let dir = TempDir::new("adopt-scan"); + let root = dir.path(); + foreign_data_root(root); + let before = snapshot(root); + + let (code, stdout, stderr) = + run_cli(&["instances", "scan", "--json", "--data-dir", &dir.arg()]); + assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}"); + let after = snapshot(root); + assert_unchanged(&before, &after, "scan 之后"); + + let value = parse_json(&stdout); + assert_eq!(value["data"]["counts"]["total"], 2); + assert_eq!(value["data"]["counts"]["adoptable"], 1); + let candidates = value["data"]["candidates"].as_array().expect("candidates"); + let ok = candidates + .iter() + .find(|item| item["version_id"] == "1.20.1") + .expect("有 1.20.1"); + assert_eq!(ok["adoptable"], true); + assert_eq!(ok["jar_present"], true); + let orphan = candidates + .iter() + .find(|item| item["version_id"] == "1.20.1-orphan") + .expect("有 1.20.1-orphan"); + assert_eq!(orphan["adoptable"], false); + assert_eq!(orphan["problems"][0]["code"], "missing_parent"); +} + +/// 把全局隔离档设成 `isolated`(走 schema 校验的正式入口,不手写 launcher.json)。 +fn set_global_isolated(dir: &TempDir) { + let dir_arg = dir.arg(); + let (code, _, stderr) = run_cli(&[ + "config", + "set", + "instances.mode", + "isolated", + "--data-dir", + dir_arg.as_str(), + ]); + assert_eq!(code, 0, "设置全局隔离档失败:{stderr}"); +} diff --git a/crates/cli/tests/adopt_test/cli_surface.rs b/crates/cli/tests/adopt_test/cli_surface.rs new file mode 100644 index 0000000..8082484 --- /dev/null +++ b/crates/cli/tests/adopt_test/cli_surface.rs @@ -0,0 +1,102 @@ +//! 命令面:help 文案、用法错误、错误码——不碰数据或者只碰「拒绝」那条路。 +//! +//! 从 `tests/adopt_test.rs` 拆出来只为守 400 行硬线;搬动是纯搬,断言未变。 + +use crate::support::{parse_json, run_cli, TempDir}; + +use crate::fixture::foreign_data_root; + +/// 运行目录选项的用法错误:一律退出码 1 + `code=usage`(不是内部错误)。 +/// +/// 抓得住:`--isolation custom` 少 `--game-dir` 被默默当成缺省、`shared` 同时给 `--game-dir` +/// 被静默忽略、未知词被当缺省。 +/// 抓不住:提示文案的措辞(只断言 code 与关键字)。 +#[test] +fn isolation_option_misuse_is_a_usage_error() { + let dir = TempDir::new("adopt-usage"); + foreign_data_root(dir.path()); + let cases: Vec> = vec![ + vec!["--isolation", "custom"], + vec!["--isolation", "shared", "--game-dir", "C:/tmp/x"], + vec!["--isolation", "nope"], + vec!["--game-dir", "relative/dir"], + ]; + for extra in cases { + let mut args = vec!["instances", "adopt", "1.20.1", "--json", "--data-dir"]; + let dir_arg = dir.arg(); + args.push(&dir_arg); + args.extend(extra.iter().copied()); + let (code, stdout, stderr) = run_cli(&args); + assert_eq!(code, 1, "args={args:?}\nstdout={stdout}\nstderr={stderr}"); + let value = parse_json(&stdout); + assert_eq!( + value["error"]["code"], "usage", + "args={args:?} 应是用法错误:{stdout}" + ); + } +} + +/// 不存在的版本 ⇒ `adopt_version_not_found`(不联网、不偷偷装一个)。 +/// +/// 抓得住:不存在的 id 被登记成功。 +/// 抓不住:`versions/` 整个不存在时 `scan` 的空结果(core 侧用例覆盖)。 +#[test] +fn unknown_version_reports_not_found() { + let dir = TempDir::new("adopt-unknown"); + foreign_data_root(dir.path()); + let (code, stdout, _) = run_cli(&[ + "instances", + "adopt", + "9.9.9", + "--json", + "--data-dir", + &dir.arg(), + ]); + assert_eq!(code, 1, "{stdout}"); + assert_eq!( + parse_json(&stdout)["error"]["code"], + "adopt_version_not_found" + ); +} + +/// help 里必须有这两条命令(新命令不留「有实现没文档」的状态),且旧命令仍在。 +/// +/// 抓得住:help 漏列 `instances scan` / `instances adopt`(子串断言),或把旧的 +/// `instances launch-args` / `pack import` 弄丢。 +/// 抓不住:帮助文案与真实行为是否一致(那要靠上面的行为用例 + 人读)。 +#[test] +fn help_lists_the_new_subcommands_next_to_the_old_ones() { + let general = unlucky_cli::cli::help::general(); + for needle in [ + "instances scan", + "instances adopt", + "instances list", + "install ", + ] { + assert!(general.contains(needle), "总帮助缺 `{needle}`:\n{general}"); + } + let topic = unlucky_cli::cli::help::topic("instances").expect("help instances 存在"); + for needle in [ + "instances scan", + "instances adopt", + "缺省不沿用全局 isolated", + "一个字节都不写", + ] { + assert!(topic.contains(needle), "help instances 缺 `{needle}`"); + } + // 旧的 `per_version` 字面量只在 core 的 `Isolation::parse` 里被兼容,CLI 词表不接受它。 + let bogus = std::env::temp_dir().join("ull-adopt-help-nonexistent"); + let bogus = bogus.display().to_string(); + let args = [ + "instances", + "adopt", + "1.20.1", + "--isolation", + "per_version", + "--json", + "--data-dir", + bogus.as_str(), + ]; + let (code, stdout, _) = run_cli(&args); + assert_eq!(code, 1, "`per_version` 不是 CLI 词表:{stdout}"); +} diff --git a/crates/cli/tests/adopt_test/fixture.rs b/crates/cli/tests/adopt_test/fixture.rs new file mode 100644 index 0000000..7477dd1 --- /dev/null +++ b/crates/cli/tests/adopt_test/fixture.rs @@ -0,0 +1,140 @@ +//! CLI 侧夹具:一个「别的启动器铺好了 `.minecraft`」的数据根 + 全量快照工具。 + +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`): +/// +/// | 路径 | 用意 | +/// | --- | --- | +/// | `versions/1.20.1/` | 正常版本(清单 + jar),可登记、可 `launch --dry-run` | +/// | `versions/1.20.1-orphan/` | 缺父版本 ⇒ 不可登记 | +/// | `libraries/` `assets/` | 恒共享层(登记不该碰) | +/// | `mods/` `saves/` | 玩家数据(登记不该碰) | +/// | `launcher_profiles.json` | 别的启动器的元数据(连读都不该读,更不该写) | +pub fn foreign_data_root(root: &Path) { + let minecraft = root.join(".minecraft"); + let versions = minecraft.join("versions"); + write_version(&versions, "1.20.1", MANIFEST_OK, true); + write_version(&versions, "1.20.1-orphan", MANIFEST_ORPHAN, true); + write_file( + &minecraft.join("libraries/com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar"), + b"library-bytes", + ); + write_file( + &minecraft.join("assets/indexes/5.json"), + b"{\"objects\":{}}", + ); + write_file(&minecraft.join("mods/jei.jar"), b"mod-bytes"); + write_file(&minecraft.join("saves/World/level.dat"), b"save-bytes"); + write_file( + &minecraft.join("launcher_profiles.json"), + br#"{"profiles":{"1.20.1":{"name":"1.20.1","lastVersionId":"1.20.1"}}}"#, + ); +} + +/// 全量快照:相对路径 → (字节数, 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("建版本目录"); + std::fs::write(dir.join(format!("{id}.json")), manifest.as_bytes()).expect("写清单"); + if jar { + std::fs::write(dir.join(format!("{id}.jar")), b"junk").expect("写 jar"); + } +} + +fn write_file(path: &Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建父目录"); + } + std::fs::write(path, bytes).expect("写文件"); +} + +const MANIFEST_OK: &str = r#"{ + "id": "1.20.1", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": { "id": "5" }, + "arguments": { + "game": ["--username", "${auth_player_name}", "--version", "${version_name}", + "--gameDir", "${game_directory}"], + "jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"] + }, + "libraries": [], + "downloads": { "client": { "size": 4, "url": "http://example.invalid/client.jar" } } +}"#; + +const MANIFEST_ORPHAN: &str = r#"{ + "id": "1.20.1-orphan", + "type": "release", + "inheritsFrom": "1.20.1-not-on-disk", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [] +}"#; diff --git a/crates/core/src/game/adopt.rs b/crates/core/src/game/adopt.rs new file mode 100644 index 0000000..8aa49cd --- /dev/null +++ b/crates/core/src/game/adopt.rs @@ -0,0 +1,54 @@ +//! 登记「已经铺在盘上的版本」(从其他启动器的 `.minecraft` 只读接入,M221)。 +//! +//! 依据:`DevDocs/artifacts/m218/SURVEY-p4.md`(④ 从其他启动器导入数据)与 +//! `DevDocs/00-债务台账.md` §21 的第 ④ 条;模型口径仍按 D20(实例隔离只改**运行目录**, +//! `versions/` `assets/` `libraries/` 恒共享,见 [`crate::game::isolation::SHARED_LAYERS`])。 +//! +//! # 一句话纪律 +//! +//! **登记只读外部、只写自己**:本模块从不往游戏根写一个字节(不搬文件、不补清单、不改 mtime), +//! 唯一的写盘是 `profiles.json` + `config/instances/.json`(+ 缺省运行目录的准备,若选了它)。 +//! 因此「导入前后对 `versions/` `libraries/` 做全量哈希必须完全不变」是可以用测试钉死的事实。 +//! +//! # 为什么要有它(现状的洞) +//! +//! [`GameLayout::installed_versions`] 早就会扫 `versions/`,但**没有任何代码把扫到的东西变成实例**。 +//! 于是「别人的 `.minecraft` 已经装好了 1.20.1」这件事在 ULL 里看不见,用户只能重装一遍 +//! (下载全部资源)。这个模块把那批版本变成实例档案,内容一个字节不搬。 +//! +//! # 缺省隔离档**不能**沿用全局 `isolated`(本模块最要紧的一条) +//! +//! 全局 `instances.mode` 缺省是 `isolated`,而 `isolated` 的运行目录正是 `versions//` +//! ([`crate::game::isolation::derive_game_dir`])。若登记沿用这条缺省,第一次启动就会在 +//! **别人的版本目录**里长出 `mods/` `saves/` `config/` `logs/` —— 不覆盖任何文件,但与 +//! 「多启动器共存」的意图相反。所以 [`AdoptTarget`] 的缺省值是 +//! [`AdoptTarget::InstanceDir`](落 ULL 数据区 `/instances/<实例目录名>`), +//! 想共用/写进对方目录必须**显式**选,并且 [`target_notes`] 会把后果说清楚。 +//! +//! # 分层 +//! +//! - `candidate`:候选与逐项**结构化失败**(坏 JSON / 缺父 / 环 / 不可读 / 越界 / 符号链接); +//! - `error`:整次调用失败([`AdoptError`],带稳定错误码); +//! - `inspect`:只读扫描与逐项检查([`scan`] / [`inspect`]); +//! - `paths`:路径判据(版本 id 能不能当路径、符号链接有没有跑出游戏根); +//! - `plan`:把候选 + 用户选择算成一次登记的落点([`plan`]),真正落盘是 [`adopt`]。 +//! +//! # 不做什么 +//! +//! - **不读账户凭据**:`launcher_profiles.json` / `launcher_accounts.json` / HMCL 的 +//! `accounts.json` 一个都不碰(`AGENTS.md` §6 同族纪律); +//! - **不做多根**(D20 未改):扫的永远是**当前**游戏根(`core.game_home`), +//! 「同时管多个 `.minecraft`」是另一个更贵的模型改动,见 SURVEY-p4 §4.2; +//! - **不下载、不校验完整性**:版本 JSON 里 `libraries[].downloads.artifact.url` 指向 maven +//! 中心是正常形态,登记不因此拒绝,但也不假装校验过(人读输出里如实写成「未校验」)。 + +pub mod candidate; +mod error; +mod inspect; +mod paths; +mod plan; + +pub use candidate::{AdoptCandidate, AdoptProblem, AdoptTarget, RegisteredInstance}; +pub use error::AdoptError; +pub use inspect::{inspect, scan, AdoptScan}; +pub use plan::{adopt, plan, target_notes, AdoptOutcome, AdoptPlan, AdoptRequest}; diff --git a/crates/core/src/game/adopt/candidate.rs b/crates/core/src/game/adopt/candidate.rs new file mode 100644 index 0000000..0cf5a8a --- /dev/null +++ b/crates/core/src/game/adopt/candidate.rs @@ -0,0 +1,276 @@ +//! 登记的**数据形状**:候选、结构化失败、运行目录选择。 +//! +//! 三种形状刻意分开: +//! - [`AdoptCandidate`]:盘上的一个 `versions//` 是不是能登记的版本(含逐项问题); +//! - [`AdoptProblem`]:一条**结构化**的不合格原因(有稳定 `code`,人读 `message`); +//! - [`AdoptTarget`]:登记后运行目录从哪来(隔离档由它决定); +//! - 整次调用的失败形状在 [`super::error::AdoptError`]。 +//! +//! 为什么不把问题塞进 [`crate::game::GameError`]:`core::game` 的错误是「解析/路径/IO」这一层, +//! 而这里是「这个版本对我们而言能不能登记」的业务判据(缺父、越界、同名冲突…)。 +//! 混进 `GameError` 会让所有现存 `match` 多出一批只在本模块有意义的变体。 + +use std::fmt; +use std::path::PathBuf; + +use super::super::install::instance::Isolation; + +/// 候选版本的**结构化**不合格原因(每一条都带稳定机读 `code`)。 +/// +/// 判据来源:`DevDocs/artifacts/m218/SURVEY-p4.md` §3 的 copy/引用/拒绝表。任一 `Err` 都意味着 +/// 「登记了也启动不了」或「越界/踩别人的目录」,因此登记前一律拒绝,并把原因原样说给用户。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdoptProblem { + /// `versions//` 存在但没有同名清单(只剩 natives / 上个启动器的残留)。 + ManifestMissing { + /// 版本 id(目录名)。 + version_id: String, + /// 期望的清单路径 `versions//.json`。 + path: PathBuf, + }, + /// 版本 id 本身不能当路径用(空 / 含分隔符 / 盘符 / NUL / 结尾点号空格)。 + VersionIdInvalid { + /// 原始 id。 + version_id: String, + /// 人读原因。 + reason: &'static str, + }, + /// 清单文件读不出来(权限 / 被占用 / 不是 UTF-8)。 + ManifestUnreadable { + /// 清单路径。 + path: PathBuf, + /// 底层错误文本(含 Win32 错误码或 serde 行列号)。 + message: String, + }, + /// 清单不是合法 JSON(**带行号列号**,别只说「坏了」)。 + ManifestInvalidJson { + /// 清单路径。 + path: PathBuf, + /// serde_json 的错误文本(含 `line L column C`)。 + message: String, + }, + /// 清单里没有 `id`。 + ManifestMissingId { + /// 清单路径。 + path: PathBuf, + }, + /// 清单里的 `id` 与目录名不一致(启动时按清单 id 找 jar,必然找不到)。 + ManifestIdMismatch { + /// 清单路径。 + path: PathBuf, + /// 清单里的 id。 + manifest_id: String, + /// 目录名。 + directory_id: String, + }, + /// 目录遍历/元数据读不出来(权限、坏链接)。 + DirectoryUnreadable { + /// 出问题的路径。 + path: PathBuf, + /// 底层错误文本。 + message: String, + }, + /// 已有条目解析到游戏根之外(符号链接 / 目录联接 / 祖先链接;不跟随)。 + OutsideGameRoot { + /// 链接本身。 + path: PathBuf, + /// 解析出来的真实目标。 + target: PathBuf, + /// 比较基准(真实化后的游戏根)。 + root: PathBuf, + }, + /// 是符号链接但解析不出来(断链 / 权限)。 + SymlinkUnresolvable { + /// 链接本身。 + path: PathBuf, + /// 底层错误文本。 + message: String, + }, + /// `inheritsFrom` 指向的父版本在盘上找不到。 + MissingParent { + /// 缺失的父版本 id。 + parent: String, + /// 谁在要它。 + required_by: String, + }, + /// `inheritsFrom` 成环。 + CircularInheritance { + /// 从根到环的 id 链(首尾同一个 id)。 + chain: Vec, + }, + /// 继承链在 `core::game::resolve` 那里失败(走查通过、权威解析不通过的兜底)。 + InheritanceInvalid { + /// `GameError` 的人读文本。 + detail: String, + }, +} + +impl AdoptProblem { + /// 稳定机读码(`--json` 的 `problems[].code`)。 + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::ManifestMissing { .. } => "manifest_missing", + Self::VersionIdInvalid { .. } => "version_id_invalid", + Self::ManifestUnreadable { .. } => "manifest_unreadable", + Self::ManifestInvalidJson { .. } => "manifest_invalid_json", + Self::ManifestMissingId { .. } => "manifest_missing_id", + Self::ManifestIdMismatch { .. } => "manifest_id_mismatch", + Self::DirectoryUnreadable { .. } => "directory_unreadable", + Self::OutsideGameRoot { .. } => "outside_game_root", + Self::SymlinkUnresolvable { .. } => "symlink_unresolvable", + Self::MissingParent { .. } => "missing_parent", + Self::CircularInheritance { .. } => "circular_inheritance", + Self::InheritanceInvalid { .. } => "inheritance_invalid", + } + } +} + +impl fmt::Display for AdoptProblem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ManifestMissing { version_id, path } => write!( + f, + "版本 {version_id} 没有清单文件(期望 {})", + path.display() + ), + Self::VersionIdInvalid { version_id, reason } => { + write!(f, "版本 id 不能当路径用({reason}):{version_id:?}") + } + Self::ManifestUnreadable { path, message } => { + write!(f, "清单读不出来 {}:{message}", path.display()) + } + Self::ManifestInvalidJson { path, message } => { + write!(f, "清单不是合法 JSON {}:{message}", path.display()) + } + Self::ManifestMissingId { path } => { + write!(f, "清单缺少 id 字段:{}", path.display()) + } + Self::ManifestIdMismatch { + path, + manifest_id, + directory_id, + } => write!( + f, + "清单 id({manifest_id})与目录名({directory_id})不一致:{}——启动时会按清单 id 找 jar,必然找不到", + path.display() + ), + Self::DirectoryUnreadable { path, message } => { + write!(f, "目录读不出来 {}:{message}", path.display()) + } + Self::OutsideGameRoot { path, target, root } => write!( + f, + "条目解析到游戏根之外,不跟随:{} -> {}(游戏根 {})", + path.display(), + target.display(), + root.display() + ), + Self::SymlinkUnresolvable { path, message } => { + write!(f, "符号链接解析失败 {}:{message}", path.display()) + } + Self::MissingParent { parent, required_by } => { + write!(f, "版本 {required_by} 继承的父版本 {parent} 不在盘上") + } + Self::CircularInheritance { chain } => { + write!(f, "版本继承成环:{}", chain.join(" -> ")) + } + Self::InheritanceInvalid { detail } => write!(f, "继承链解析失败:{detail}"), + } + } +} + +/// 已经绑定某个版本 id 的实例档案(只带展示需要的最小信息,不泄露内部结构)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegisteredInstance { + /// 实例 id。 + pub id: String, + /// 实例展示名。 + pub name: String, +} + +/// 盘上的一个候选版本(扫出来的一个 `versions//`)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdoptCandidate { + /// 版本 id(= 目录名,登记后写进 `profiles.json` 的 `version_id`)。 + pub version_id: String, + /// 版本目录 `versions//`。 + pub version_dir: PathBuf, + /// 清单路径 `versions//.json`。 + pub manifest_path: PathBuf, + /// 客户端 jar `versions//.jar` 是否存在(只报事实,不校验内容)。 + pub jar_present: bool, + /// 清单里声明的 id(读不出来时为 `None`)。 + pub manifest_id: Option, + /// `inheritsFrom` 链(自身在前、最祖先在后;读不出来时是已走通的那一段)。 + pub inherits: Vec, + /// 已有实例绑定这个版本(空 = 还没登记过)。 + pub registered: Vec, + /// 问题清单(空 = 可登记)。 + pub problems: Vec, +} + +impl AdoptCandidate { + /// 是否可登记(没有任何问题)。 + #[must_use] + pub fn adoptable(&self) -> bool { + self.problems.is_empty() + } + + /// 已登记过(至少有一个实例绑定这个版本 id)。 + #[must_use] + pub fn is_registered(&self) -> bool { + !self.registered.is_empty() + } +} + +/// 登记后实例的**运行目录**从哪来(隔离档由它决定,见 [`AdoptTarget::isolation`])。 +/// +/// 缺省是 [`Self::InstanceDir`]——**不是** `isolated`:理由见模块文档。 +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum AdoptTarget { + /// 缺省:ULL 数据区里新建独立运行目录 `/instances/<实例目录名>`(写 `custom` 档)。 + #[default] + InstanceDir, + /// 共用当前游戏根(别人的 `.minecraft`)——必须显式选,且必须告知后果。 + Shared, + /// 版本目录 `versions//`(`isolated` 档)——会在别人的版本目录里长玩家数据。 + VersionDir, + /// 用户指定的目录(`custom` 档,必须绝对路径)。 + Custom(PathBuf), +} + +impl AdoptTarget { + /// 稳定机读 id(`--json` 的 `run_dir_source`)。 + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::InstanceDir => "instance_dir", + Self::Shared => "shared", + Self::VersionDir => "version_dir", + Self::Custom(_) => "custom", + } + } + + /// 生效隔离档。 + #[must_use] + pub const fn isolation(&self) -> Isolation { + match self { + Self::InstanceDir | Self::Custom(_) => Isolation::Custom, + Self::Shared => Isolation::Shared, + Self::VersionDir => Isolation::Isolated, + } + } + + /// 命令行/配置里的词表(`shared` / `isolated` / `custom`,与 [`Isolation`] 同一套字面量)。 + /// + /// `instance_dir` 有意**不接受**输入:它是缺省值,不是一个用户可以手写选中的档位。 + #[must_use] + pub fn parse(raw: &str, game_dir: Option) -> Option { + match raw.trim() { + "shared" => Some(Self::Shared), + "isolated" | "per_version" => Some(Self::VersionDir), + "custom" => game_dir.map(Self::Custom), + _ => None, + } + } +} diff --git a/crates/core/src/game/adopt/error.rs b/crates/core/src/game/adopt/error.rs new file mode 100644 index 0000000..c2033d6 --- /dev/null +++ b/crates/core/src/game/adopt/error.rs @@ -0,0 +1,143 @@ +//! 一次登记的失败形状(CLI 据此给稳定错误码 + 结构化 details)。 +//! +//! 与 [`AdoptProblem`] 的分工:`AdoptProblem` 是「这个版本为什么不能登记」(可以多条、逐条列出), +//! [`AdoptError`] 是「这次调用为什么没成功」(一条,喂给 `--json` 的 `error.code`)。 +//! 候选不合格是后者的一个变体,但问题清单原样带出来——不许把原因折叠成一句「导入失败」。 + +use std::fmt; +use std::path::PathBuf; + +use super::candidate::AdoptProblem; +use crate::game::error::GameError; + +/// 一次登记失败(CLI 据此给稳定错误码 + 结构化 details)。 +#[derive(Debug)] +pub enum AdoptError { + /// `core::game` 的解析/路径/IO 失败。 + Game(GameError), + /// 版本 id 在盘上不存在(没有 `versions//`)。 + UnknownVersion { + /// 请求的版本 id。 + version_id: String, + /// 期望的目录。 + version_dir: PathBuf, + }, + /// 候选不合格(`problems` 就是全部原因,一条都不吞)。 + NotAdoptable { + /// 版本 id。 + version_id: String, + /// 全部原因。 + problems: Vec, + }, + /// 展示名已被别的实例占用(**显式报错**,不静默复用一条别的版本)。 + DuplicateName { + /// 冲突的展示名。 + name: String, + /// 占用它的实例 id。 + instance_id: String, + }, + /// 这个版本已经以同名实例登记过了(重复执行 = 无副作用失败)。 + AlreadyRegistered { + /// 版本 id。 + version_id: String, + /// 已存在的实例名。 + name: String, + /// 已存在的实例 id。 + instance_id: String, + }, + /// 缺省运行目录已存在且非空(不猜「合并进已有实例」,也不覆盖)。 + TargetNotEmpty { + /// 那个目录。 + path: PathBuf, + }, + /// 运行目录选择本身不成立(`custom` 缺目录 / 相对路径)。 + TargetInvalid { + /// 人读原因。 + detail: String, + }, +} + +impl AdoptError { + /// 稳定机读码(`--json` 的 `error.code`)。 + #[must_use] + pub const fn code(&self) -> &'static str { + match self { + Self::Game(_) => "adopt_io", + Self::UnknownVersion { .. } => "adopt_version_not_found", + Self::NotAdoptable { .. } => "adopt_not_adoptable", + Self::DuplicateName { .. } => "adopt_duplicate_name", + Self::AlreadyRegistered { .. } => "adopt_already_registered", + Self::TargetNotEmpty { .. } => "adopt_target_not_empty", + Self::TargetInvalid { .. } => "adopt_target_invalid", + } + } + + /// 候选不合格时的问题清单(其它错误为空)。 + #[must_use] + pub fn problems(&self) -> &[AdoptProblem] { + match self { + Self::NotAdoptable { problems, .. } => problems, + _ => &[], + } + } +} + +impl fmt::Display for AdoptError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Game(error) => write!(f, "{error}"), + Self::UnknownVersion { + version_id, + version_dir, + } => write!( + f, + "版本 {version_id} 不在盘上(没有 {})", + version_dir.display() + ), + Self::NotAdoptable { + version_id, + problems, + } => { + write!(f, "版本 {version_id} 不能登记:")?; + for (index, problem) in problems.iter().enumerate() { + if index > 0 { + f.write_str(";")?; + } + write!(f, "{problem}")?; + } + Ok(()) + } + Self::DuplicateName { name, instance_id } => write!( + f, + "已有同名实例 `{name}`(id {instance_id}):导入时请用 --name 换一个展示名" + ), + Self::AlreadyRegistered { + version_id, + name, + instance_id, + } => write!( + f, + "版本 {version_id} 已经登记过(实例 `{name}`,id {instance_id}):没有改动任何文件" + ), + Self::TargetNotEmpty { path } => { + write!(f, "运行目录已存在且非空,不覆盖:{}", path.display()) + } + Self::TargetInvalid { detail } => write!(f, "运行目录选择不成立:{detail}"), + } + } +} + +impl std::error::Error for AdoptError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Game(error) => Some(error), + _ => None, + } + } +} + +impl From for AdoptError { + fn from(error: GameError) -> Self { + Self::Game(error) + } +} diff --git a/crates/core/src/game/adopt/inspect.rs b/crates/core/src/game/adopt/inspect.rs new file mode 100644 index 0000000..9db7b57 --- /dev/null +++ b/crates/core/src/game/adopt/inspect.rs @@ -0,0 +1,353 @@ +//! 只读扫描与逐项检查:`versions/` 下哪些版本能登记、不能登记的原因是什么。 +//! +//! # 三条不许偷懒的判据 +//! +//! 1. **坏东西不能当空列表**:`versions/` 读不了是错误([`AdoptError::Game`]),一个版本的清单坏了是 +//! 一条 [`AdoptProblem`];两条都不会变成「这里什么都没有」。 +//! 2. **继承链要走到头**:缺父、成环、父清单坏了、父的清单 id 与目录名不一致,逐条报清楚是哪个文件。 +//! 3. **不跟随越界符号链接**:`versions/` 或清单是符号链接时,解析出的真实路径必须仍在游戏根内 +//! (与导出侧「符号链接不跟随」同一政策)。 +//! +//! 全程**只读**:不建目录、不写文件、不改 mtime。 + +use std::path::{Path, PathBuf}; + +use super::super::error::GameError; +use super::super::inherit; +use super::super::install::instance::read_profiles; +use super::super::layout::GameLayout; +use super::super::manifest::VersionJson; +use super::super::profiles::ProfileList; +use super::candidate::{AdoptCandidate, AdoptProblem, RegisteredInstance}; +use super::error::AdoptError; +use super::paths::{ensure_inside, root_of, validate_version_id}; +use crate::platform::layout::LauncherLayout; + +/// 一次扫描的结果(`versions/` 里的全部条目,逐项带问题)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdoptScan { + /// 被扫的目录(`<游戏根>/versions`)。 + pub versions_dir: PathBuf, + /// 候选版本(按版本 id 字典序)。 + pub candidates: Vec, +} + +/// 扫当前游戏根的 `versions/`,逐个检查。 +/// +/// `versions/` **不存在** ⇒ 空结果(新装的数据根,没有可登记的东西,不是错误); +/// `versions/` 存在但读不了 ⇒ [`AdoptError::Game`](绝不降级成空列表)。 +/// +/// # Errors +/// +/// 读目录 / 读 `profiles.json` 失败;条目元数据读不出来(权限)时也直接失败——那属于 +/// 「环境坏了」而不是「这个版本不能登记」。 +pub fn scan(game: &GameLayout, launcher: &LauncherLayout) -> Result { + let profiles = read_profiles(launcher)?; + let versions_dir = game.versions_dir(); + let root = root_of(game); + let entries = match std::fs::read_dir(&versions_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(AdoptScan { + versions_dir, + candidates: Vec::new(), + }) + } + Err(error) => return Err(GameError::io(&versions_dir, error).into()), + }; + let mut candidates: Vec = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| GameError::io(&versions_dir, error))?; + let path = entry.path(); + let Some(version_id) = path.file_name().and_then(|name| name.to_str()) else { + // 非 UTF-8 目录名不能当版本 id(进不了 profiles.json),如实记一条问题而不是丢掉。 + candidates.push(unreadable_id_candidate(&path, &versions_dir)); + continue; + }; + match std::fs::metadata(&path) { + Ok(meta) if meta.is_dir() => {} + Ok(_) => continue, // `versions/` 里的普通文件不是版本目录 + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // 断链符号链接:元数据跟不过去,但链接本身在。 + if std::fs::symlink_metadata(&path).is_ok() { + candidates.push(broken_link_candidate(version_id, &path)); + } + continue; + } + Err(error) => return Err(GameError::io(&path, error).into()), + } + candidates.push(check(game, &root, version_id, &profiles)); + } + candidates.sort_by(|a, b| a.version_id.cmp(&b.version_id)); + Ok(AdoptScan { + versions_dir, + candidates, + }) +} + +/// 检查单个版本。 +/// +/// 与 [`scan`] 用同一套判据,但**先**校验 id 能不能当路径用(非法 id 连存在性都不该去探, +/// 否则 `versions/../../外面的/1.20.1` 这种形状会先撞上「目录真的存在」再报出别的结论)。 +/// 候选不合格一律 [`AdoptError::NotAdoptable`] 且把问题清单原样带出,只有环境级失败才 `Err`。 +/// +/// # Errors +/// +/// - [`AdoptError::NotAdoptable`]:id 非法,或候选有问题(坏清单 / 缺父 / 环 / 越界…); +/// - [`AdoptError::UnknownVersion`]:id 合法但 `versions//` 不存在; +/// - [`AdoptError::Game`]:读 `profiles.json` 失败。 +pub fn inspect( + game: &GameLayout, + launcher: &LauncherLayout, + version_id: &str, +) -> Result { + if let Err(problem) = validate_version_id(version_id) { + return Err(AdoptError::NotAdoptable { + version_id: version_id.to_owned(), + problems: vec![problem], + }); + } + if !game.version_dir(version_id).is_dir() { + return Err(AdoptError::UnknownVersion { + version_id: version_id.to_owned(), + version_dir: game.version_dir(version_id), + }); + } + let profiles = read_profiles(launcher)?; + let candidate = check(game, &root_of(game), version_id, &profiles); + if candidate.adoptable() { + Ok(candidate) + } else { + Err(AdoptError::NotAdoptable { + version_id: candidate.version_id.clone(), + problems: candidate.problems.clone(), + }) + } +} + +/// 逐项检查一个版本目录。 +fn check( + game: &GameLayout, + root: &Path, + version_id: &str, + profiles: &ProfileList, +) -> AdoptCandidate { + let version_dir = game.version_dir(version_id); + let manifest_path = game.version_json(version_id); + let registered = registered_for(profiles, version_id); + let mut candidate = AdoptCandidate { + version_id: version_id.to_owned(), + version_dir: version_dir.clone(), + manifest_path: manifest_path.clone(), + jar_present: false, + manifest_id: None, + inherits: Vec::new(), + registered, + problems: Vec::new(), + }; + + // ① id 能不能当路径用:不能的话后面的 join 全不可信,直接结束。 + if let Err(problem) = validate_version_id(version_id) { + candidate.problems.push(problem); + return candidate; + } + // ② 版本目录解析到根外(符号链接 / 目录联接 / 祖先链接)⇒ 立刻停手: + // **一个字节都不去读**那个目录(读它就等于读别人的启动器数据)。 + if let Some(problem) = ensure_inside(&version_dir, root) { + candidate.problems.push(problem); + return candidate; + } + // ③ 清单必须在(`installed_versions` 的判据同源)。 + if !manifest_path.is_file() { + candidate.problems.push(AdoptProblem::ManifestMissing { + version_id: version_id.to_owned(), + path: manifest_path, + }); + return candidate; + } + if let Some(problem) = ensure_inside(&manifest_path, root) { + candidate.problems.push(problem); + return candidate; + } + candidate.jar_present = game.version_jar(version_id).is_file(); + + // ④ 清单读得出来、id 对得上。 + let manifest = match read_manifest(&manifest_path) { + Ok(manifest) => manifest, + Err(problem) => { + candidate.problems.push(problem); + return candidate; + } + }; + let manifest_id = manifest.id.clone(); + candidate.manifest_id = manifest_id.clone(); + match manifest_id.as_deref() { + None => { + candidate.problems.push(AdoptProblem::ManifestMissingId { + path: manifest_path.clone(), + }); + return candidate; + } + Some(id) if id != version_id => { + candidate.problems.push(AdoptProblem::ManifestIdMismatch { + path: manifest_path.clone(), + manifest_id: id.to_owned(), + directory_id: version_id.to_owned(), + }); + } + Some(_) => {} + } + + // ⑤ 继承链走到底(缺父 / 环 / 父清单坏 / 父 id 不匹配都在这条路上报)。 + match walk_chain(game, root, &manifest_path, manifest) { + Ok(chain) => { + candidate.inherits = chain; + // 走查通过后再让 `resolve` 做一次权威判定:两处判据不许漂。 + if let Some(problem) = resolve_verdict(game, &candidate.manifest_path) { + candidate.problems.push(problem); + } + } + Err(problem) => candidate.problems.push(problem), + } + candidate +} + +/// 走 `inheritsFrom` 链,返回 [自身, 父, 祖父, …];任一层不合格就返回那条问题。 +fn walk_chain( + game: &GameLayout, + root: &Path, + manifest_path: &Path, + manifest: VersionJson, +) -> Result, AdoptProblem> { + let Some(self_id) = manifest.id.clone() else { + return Err(AdoptProblem::ManifestMissingId { + path: manifest_path.to_path_buf(), + }); + }; + let mut chain = vec![self_id]; + let mut current = manifest; + while let Some(parent) = current.inherits_from.clone() { + if chain.iter().any(|id| id == &parent) { + let mut loop_chain = chain.clone(); + loop_chain.push(parent); + return Err(AdoptProblem::CircularInheritance { chain: loop_chain }); + } + validate_version_id(&parent)?; + let required_by = chain.last().cloned().unwrap_or_default(); + let parent_dir = game.version_dir(&parent); + let parent_path = game.version_json(&parent); + // 父版本同样先过边界(越界就不读),再判存在。 + if let Some(problem) = ensure_inside(&parent_dir, root) { + return Err(problem); + } + if !parent_path.is_file() { + return Err(AdoptProblem::MissingParent { + parent, + required_by, + }); + } + if let Some(problem) = ensure_inside(&parent_path, root) { + return Err(problem); + } + let parent_manifest = read_manifest(&parent_path)?; + match parent_manifest.id.as_deref() { + None => return Err(AdoptProblem::ManifestMissingId { path: parent_path }), + Some(id) if id != parent => { + return Err(AdoptProblem::ManifestIdMismatch { + path: parent_path, + manifest_id: id.to_owned(), + directory_id: parent, + }) + } + Some(_) => {} + } + chain.push(parent); + current = parent_manifest; + } + Ok(chain) +} + +/// 用 `core::game::resolve` 判一次继承链(权威结论;失败就如实报,不猜)。 +fn resolve_verdict(game: &GameLayout, manifest_path: &Path) -> Option { + let manifest = read_manifest(manifest_path).ok()?; + let source = |id: &str| -> Option { + let bytes = std::fs::read(game.version_json(id)).ok()?; + VersionJson::from_json_slice(&bytes).ok() + }; + match inherit::resolve(&source, &manifest) { + Ok(_) => None, + Err(error) => Some(AdoptProblem::InheritanceInvalid { + detail: error.to_string(), + }), + } +} + +/// 读一份清单(错误带路径与 serde 的行列号)。 +fn read_manifest(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|error| AdoptProblem::ManifestUnreadable { + path: path.to_path_buf(), + message: error.to_string(), + })?; + VersionJson::from_json_slice(&bytes).map_err(|error| match error { + GameError::Json { source, .. } => AdoptProblem::ManifestInvalidJson { + path: path.to_path_buf(), + message: source.to_string(), + }, + other => AdoptProblem::ManifestInvalidJson { + path: path.to_path_buf(), + message: other.to_string(), + }, + }) +} + +/// 绑定这个版本 id 的全部实例档案。 +fn registered_for(profiles: &ProfileList, version_id: &str) -> Vec { + profiles + .profiles + .iter() + .filter(|profile| profile.version_id == version_id) + .map(|profile| RegisteredInstance { + id: profile.id.as_str().to_owned(), + name: profile.name.clone(), + }) + .collect() +} + +/// 非 UTF-8 目录名:当成一条不可登记的候选(丢掉它就等于「静默少一行」)。 +fn unreadable_id_candidate(path: &Path, versions_dir: &Path) -> AdoptCandidate { + let shown = path + .strip_prefix(versions_dir) + .unwrap_or(path) + .to_string_lossy() + .into_owned(); + AdoptCandidate { + version_id: shown, + version_dir: path.to_path_buf(), + manifest_path: path.to_path_buf(), + jar_present: false, + manifest_id: None, + inherits: Vec::new(), + registered: Vec::new(), + problems: vec![AdoptProblem::DirectoryUnreadable { + path: path.to_path_buf(), + message: "目录名不是合法 UTF-8,不能作为版本 id".to_owned(), + }], + } +} + +/// 断链符号链接:`versions/` 里有这个条目,但指不到任何东西。 +fn broken_link_candidate(version_id: &str, path: &Path) -> AdoptCandidate { + AdoptCandidate { + version_id: version_id.to_owned(), + version_dir: path.to_path_buf(), + manifest_path: path.to_path_buf(), + jar_present: false, + manifest_id: None, + inherits: Vec::new(), + registered: Vec::new(), + problems: vec![AdoptProblem::SymlinkUnresolvable { + path: path.to_path_buf(), + message: "符号链接的目标不存在(断链)".to_owned(), + }], + } +} diff --git a/crates/core/src/game/adopt/paths.rs b/crates/core/src/game/adopt/paths.rs new file mode 100644 index 0000000..2258033 --- /dev/null +++ b/crates/core/src/game/adopt/paths.rs @@ -0,0 +1,84 @@ +//! 路径判据:版本 id 能不能当路径用、已有条目是不是跑出了游戏根(符号链接 / 目录联接 / 祖先链接)。 +//! +//! 两条判据都与「登记只读」直接相关:id 不可信 ⇒ `versions//` 可能落到别人的目录上; +//! 越界 ⇒ 我们以为在游戏根里的东西其实在外面(读它就等于读别的启动器的数据)。 +//! 判据写成纯函数,好在这一层单独变异。 + +use std::path::{Path, PathBuf}; + +use super::super::layout::GameLayout; +use super::candidate::AdoptProblem; + +/// 游戏根的「真实」形式(符号链接越界判据的比较基准)。 +/// +/// 拿不到(根还不存在 / 无权限)时退回原始路径:那种情况下不会有已存在的符号链接能通过元数据检查, +/// 越界判据仍能给出「不在根内」的结论,而不是静默放行。 +pub(super) fn root_of(game: &GameLayout) -> PathBuf { + game.root() + .canonicalize() + .unwrap_or_else(|_| game.root().to_path_buf()) +} + +/// 边界判据:**已存在**的路径,其真实(解析符号链接/目录联接之后)位置必须在游戏根内。 +/// +/// 为什么不是 `is_symlink` 一刀切: +/// - `is_symlink` 看不到 Windows 的**目录联接(junction)**和大部分重解析点——而 junction 不需要 +/// 管理员权限就能建,正是「把别人的版本目录挂进来」最容易的形态; +/// - 祖先链接同样要算:`versions/` 本身是 junction 时,每个 `versions/` 都解析到根外, +/// 单看 `` 这一层还是普通目录。 +/// +/// 所以这里一律 `canonicalize` 后与**真实游戏根**做前缀比较:根内的链接照旧放行(合法的省盘做法), +/// 根外的一律给出 [`AdoptProblem::OutsideGameRoot`]。路径不存在 ⇒ `None`(存在性由调用方的 +/// 「清单缺失」判据负责,别把「还没装」说成「越界」)。 +/// +/// **已知边界**:判据是「检查那一刻」的快照,检查与读取之间若有人把目录换成链接(TOCTOU), +/// 本函数看不到——本模块不是并发/对抗入口,不做跨进程锁。 +pub(super) fn ensure_inside(path: &Path, root: &Path) -> Option { + if std::fs::symlink_metadata(path).is_err() { + return None; + } + match path.canonicalize() { + Ok(target) if target.starts_with(root) => None, + Ok(target) => Some(AdoptProblem::OutsideGameRoot { + path: path.to_path_buf(), + target, + root: root.to_path_buf(), + }), + Err(error) => Some(AdoptProblem::SymlinkUnresolvable { + path: path.to_path_buf(), + message: error.to_string(), + }), + } +} + +/// 版本 id 的路径安全判据(空 / 分隔符 / 盘符 / NUL / 结尾点号空格都拒绝)。 +/// +/// 结尾的点号与空格必须拒绝:Win32 会在建目录时把它们**静默剥掉**,于是 +/// `versions/1.20.1.` 与 `versions/1.20.1` 会落到同一个目录上——两个「不同 id」指向一份文件。 +pub(super) fn validate_version_id(version_id: &str) -> Result<(), AdoptProblem> { + let invalid = |reason: &'static str| { + Err(AdoptProblem::VersionIdInvalid { + version_id: version_id.to_owned(), + reason, + }) + }; + if version_id.trim().is_empty() { + return invalid("为空"); + } + if version_id.contains('/') || version_id.contains('\\') { + return invalid("含路径分隔符"); + } + if version_id.contains(':') { + return invalid("含冒号(盘符 / NTFS 数据流)"); + } + if version_id.contains('\0') || version_id.chars().any(|ch| ch.is_control()) { + return invalid("含控制字符"); + } + if version_id == "." || version_id == ".." { + return invalid("是相对路径标记"); + } + if version_id.ends_with('.') || version_id.ends_with(' ') { + return invalid("以点号或空格结尾(Win32 会剥掉,落到别的目录上)"); + } + Ok(()) +} diff --git a/crates/core/src/game/adopt/plan.rs b/crates/core/src/game/adopt/plan.rs new file mode 100644 index 0000000..d80ed15 --- /dev/null +++ b/crates/core/src/game/adopt/plan.rs @@ -0,0 +1,324 @@ +//! 落点计算与真正的登记:`plan`(纯算 + 零写盘)与 `adopt`(plan + 只写自己的两份配置)。 +//! +//! # 写盘面(全部在 ULL 数据根下,游戏根一个字节都不动) +//! +//! 1. `config/instances/.json`(实例设置:隔离档 + 运行目录); +//! 2. `config/profiles.json`(档案列表); +//! 3. 选了缺省运行目录时,`/instances/<实例目录名>` 这个空目录(已存在且非空 ⇒ 拒绝; +//! 路径上的已有祖先若解析到**数据根之外**(符号链接/目录联接)⇒ 拒绝,不沿它写盘)。 +//! +//! `versions/` `libraries/` `assets/` **一个字节都不写**:登记只填 `version_id`,内容靠 D20 的 +//! 「恒共享层」被启动/下载链路复用([`crate::game::isolation::SHARED_LAYERS`])。 + +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::candidate::{AdoptCandidate, AdoptTarget}; +use super::error::AdoptError; +use super::inspect::inspect; +use crate::platform::layout::LauncherLayout; + +/// 一次登记请求(CLI / presenter 的输入形状)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdoptRequest { + /// 要接进来的版本 id(= `versions//`)。 + pub version_id: String, + /// 展示名;缺省 = 版本 id。 + pub name: Option, + /// 运行目录选择(缺省 [`AdoptTarget::InstanceDir`],**不是** `isolated`)。 + pub target: AdoptTarget, + /// 档案的创建时间(RFC-3339;core 不碰时钟格式,由调用方给)。 + pub created_at: Option, +} + +impl AdoptRequest { + /// 最小请求(缺省名 + 缺省运行目录)。 + #[must_use] + pub fn new(version_id: impl Into) -> Self { + Self { + version_id: version_id.into(), + name: None, + target: AdoptTarget::default(), + created_at: None, + } + } +} + +/// 一次登记的**计划**(不写任何东西;`--dry-run` / GUI 预检就吃这个)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdoptPlan { + /// 检查过的候选(含问题清单,正常时为空)。 + pub candidate: AdoptCandidate, + /// 将写进档案的展示名。 + pub name: String, + /// 运行目录选择。 + pub target: AdoptTarget, + /// 生效隔离档。 + pub isolation: Isolation, + /// 生效运行目录(= `--gameDir`)。 + pub run_dir: PathBuf, + /// 人读提示(共享/版本目录的后果、已有同版本实例…)。 + pub notes: Vec, +} + +/// 一次登记的结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdoptOutcome { + /// 实例 id(稳定 UUID)。 + pub instance_id: String, + /// 展示名。 + pub name: String, + /// 绑定的版本 id。 + pub version_id: String, + /// 实例设置文件路径。 + pub config_path: PathBuf, + /// 生效隔离档。 + pub isolation: Isolation, + /// 运行目录选择。 + pub target: AdoptTarget, + /// 生效运行目录。 + pub run_dir: PathBuf, + /// 是否新建了档案(`false` 只可能是复用同名同版本档案,本命令会先拒绝那种情况)。 + pub created: bool, + /// 是否新建了缺省运行目录(`false` = 目录已存在但为空、直接复用)。 + pub dir_created: bool, + /// 人读提示(与 [`AdoptPlan::notes`] 同一份)。 + pub notes: Vec, +} + +/// 计算一次登记:检查候选 + 解析名字 + 查重名 + 算运行目录。**零写盘**。 +/// +/// # Errors +/// +/// - [`AdoptError::UnknownVersion`]:版本不在盘上; +/// - [`AdoptError::NotAdoptable`]:候选有问题(清单坏/缺父/环/越界…,原因逐条给出;由 +/// [`inspect`] 判出来); +/// - [`AdoptError::DuplicateName`] / [`AdoptError::AlreadyRegistered`]:名字冲突 / 重复登记; +/// - [`AdoptError::TargetNotEmpty`] / [`AdoptError::TargetInvalid`]:运行目录不可用; +/// - [`AdoptError::Game`]:`profiles.json` 读不了。 +pub fn plan( + game: &GameLayout, + launcher: &LauncherLayout, + request: &AdoptRequest, +) -> Result { + // `inspect` 只在候选**可登记**时返回 `Ok`,不合格一律是 `NotAdoptable`(问题清单已带出)。 + let candidate = inspect(game, launcher, &request.version_id)?; + let name = resolve_name(request.name.as_deref(), &candidate.version_id); + let profiles = read_profiles(launcher)?; + let mut notes = Vec::new(); + // 重名与重复登记:**显式报错**,不静默复用(复用会把用户「导入这个」的意图改成「改一个别的实例」)。 + if let Some(existing) = profiles + .profiles + .iter() + .find(|profile| profile.name == name) + { + if existing.version_id == candidate.version_id { + return Err(AdoptError::AlreadyRegistered { + version_id: candidate.version_id.clone(), + name, + instance_id: existing.id.as_str().to_owned(), + }); + } + return Err(AdoptError::DuplicateName { + name, + instance_id: existing.id.as_str().to_owned(), + }); + } + for profile in &candidate.registered { + notes.push(format!( + "这个版本已经有实例了:`{}`(id {})——再登记会得到第二个实例,两个实例共用同一份版本文件", + profile.name, profile.id + )); + } + + let run_dir = run_dir_of( + game, + launcher, + &candidate.version_id, + &name, + &request.target, + )?; + notes.extend(target_notes(&request.target, &run_dir)); + + Ok(AdoptPlan { + candidate, + name, + target: request.target.clone(), + isolation: request.target.isolation(), + run_dir, + notes, + }) +} + +/// 按计划登记:先准备缺省运行目录(若选了它),再写实例设置与档案。 +/// +/// # Errors +/// +/// 同 [`plan`],外加建目录/写配置失败([`AdoptError::Game`])。 +pub fn adopt( + game: &GameLayout, + launcher: &LauncherLayout, + request: &AdoptRequest, +) -> Result { + let plan = plan(game, launcher, request)?; + let mut dir_created = false; + // 目录先建:建不出来就一个配置都不写(失败的残留是一个空目录,可复用)。 + if plan.target == AdoptTarget::InstanceDir && !plan.run_dir.is_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)?; + } + let settings = InstanceSettings { + isolation: Some(plan.isolation), + game_dir: match &plan.target { + // `custom` 档必须有运行目录:缺省目录与用户目录都要落进 `gameDir`。 + AdoptTarget::InstanceDir | AdoptTarget::Custom(_) => Some(plan.run_dir.clone()), + AdoptTarget::Shared | AdoptTarget::VersionDir => None, + }, + }; + let registration = register_with( + launcher, + &plan.name, + &plan.candidate.version_id, + request.created_at.as_deref(), + &settings, + )?; + Ok(AdoptOutcome { + instance_id: registration.profile.id.as_str().to_owned(), + name: registration.profile.name.clone(), + version_id: registration.profile.version_id.clone(), + config_path: registration.config_path, + isolation: plan.isolation, + target: plan.target, + run_dir: plan.run_dir, + created: registration.created, + dir_created, + notes: plan.notes, + }) +} + +/// 运行目录的派生(缺省落点复用整合包导入的 `/instances/<实例目录名>`)。 +fn run_dir_of( + game: &GameLayout, + launcher: &LauncherLayout, + version_id: &str, + name: &str, + target: &AdoptTarget, +) -> Result { + 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()) + } + } +} + +/// 运行目录选择的后果说明(人读;CLI 与 GUI 共用同一份话术)。 +/// +/// `shared` / `version_dir` 必须**明确告知**:它们会把玩家数据写进别人的 `.minecraft` +/// (D20 的 `isolated` 档运行目录就是 `versions//`)。 +#[must_use] +pub fn target_notes(target: &AdoptTarget, run_dir: &Path) -> Vec { + match target { + AdoptTarget::InstanceDir => vec![format!( + "运行目录 = {}(ULL 数据区新建,与别人的 .minecraft 隔离;versions/libraries/assets 仍按 D20 恒共享,不复制)", + run_dir.display() + )], + AdoptTarget::Shared => vec![format!( + "运行目录 = 游戏根 {}(共享档):mods/saves/config 会落在别人的 .minecraft 里,与其它启动器共用;\ + 要隔离请用缺省(ULL 数据区新目录)或 --isolation custom --game-dir ", + run_dir.display() + )], + AdoptTarget::VersionDir => vec![format!( + "运行目录 = 版本目录 {}(版本隔离档):第一次启动会在这里新增 mods/saves/config/logs,\ + 而这个目录是别的启动器的版本目录(不覆盖已有文件);要隔离请用缺省或 --isolation custom", + run_dir.display() + )], + AdoptTarget::Custom(_) => vec![format!( + "运行目录 = {}(用户指定;不新建、不改动其中已有内容)", + run_dir.display() + )], + } +} + +/// 展示名:去空格,空串退回版本 id(与 `pack import` 的「名字不给就用包名」同一口径)。 +fn resolve_name(name: Option<&str>, version_id: &str) -> String { + name.map(str::trim) + .filter(|name| !name.is_empty()) + .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/install/instance.rs b/crates/core/src/game/install/instance.rs index 3c86c2a..472f9c0 100644 --- a/crates/core/src/game/install/instance.rs +++ b/crates/core/src/game/install/instance.rs @@ -16,7 +16,8 @@ //! # 幂等 //! //! 同一版本重复 `install`:按 `version_id` + 展示名复用已有档案,返回 `created = false`, -//! 不产生第二个实例、不改已有实例的 id(id 一旦写进档案就是稳定的)。 +//! 不产生第二个实例、不改已有实例的 id(id 一旦写进档案就是稳定的)。登记本身(含 +//! [`InstanceSettings`] 与 `register_with`)在同目录的 `register` 子模块里。 use std::collections::BTreeMap; use std::path::PathBuf; @@ -26,10 +27,14 @@ use serde::{Deserialize, Serialize}; use super::super::error::GameError; use super::super::isolation::{IsolationMode, ModeChange, ModeChangeWarning}; use super::super::launch_overrides::LaunchOverrides; -use super::super::profiles::{InstanceProfile, ProfileList}; +use super::super::profiles::ProfileList; use crate::platform::atomic::write_atomic; use crate::platform::layout::LauncherLayout; +mod register; + +pub use register::{register, register_with, InstanceSettings, Registration}; + /// 每实例设置文件的 schema 版本。 /// /// 与 `setting::instance_keys::instance_version` 的 minor 对应:D20 加了 `lastModeChange`, @@ -215,71 +220,6 @@ impl InstanceConfig { } } -/// 一次登记的结果。 -#[derive(Debug, Clone)] -pub struct Registration { - /// 档案(新建或复用)。 - pub profile: InstanceProfile, - /// 每实例设置文件路径。 - pub config_path: PathBuf, - /// 是否新建(`false` = 已有同版本同名的实例,直接复用)。 - pub created: bool, -} - -/// 登记一个实例(幂等)。 -/// -/// 行为: -/// 1. 已有"同 `version_id` + 同展示名"的档案 → 复用(`created = false`),只补写缺失的设置文件; -/// 2. 否则新建档案(稳定 UUID)→ 写 `profiles.json` → 写 `config/instances/.json`。 -/// -/// `created_at` 由调用方给(core::game 不碰时钟格式;CLI 用 `launch::time` 的 RFC3339)。 -/// -/// # Errors -/// -/// 读/写 `profiles.json`、实例 id 不合法、原子写失败。 -pub fn register( - layout: &LauncherLayout, - name: &str, - version_id: &str, - created_at: Option<&str>, -) -> Result { - let mut profiles = read_profiles(layout)?; - let existing = profiles - .profiles - .iter() - .find(|profile| profile.version_id == version_id && profile.name == name) - .cloned(); - - if let Some(profile) = existing { - let config_path = instance_config_path(layout, profile.id.as_str())?; - // 老档案可能还没有设置文件(早期 CLI 只写 profiles.json),这里补齐。 - if !config_path.is_file() { - write_instance_config( - layout, - &InstanceConfig::new(profile.id.as_str(), version_id), - )?; - } - return Ok(Registration { - profile, - config_path, - created: false, - }); - } - - let mut profile = InstanceProfile::new(name, version_id); - profile.created_at = created_at.map(str::to_string); - let id = profile.id.as_str().to_string(); - profiles.upsert(profile.clone()); - write_profiles(layout, &profiles)?; - - let config_path = write_instance_config(layout, &InstanceConfig::new(id, version_id))?; - Ok(Registration { - profile, - config_path, - created: true, - }) -} - /// 读实例清单(文件缺失 = 空列表,不算错误)。 /// /// # Errors diff --git a/crates/core/src/game/install/instance/register.rs b/crates/core/src/game/install/instance/register.rs new file mode 100644 index 0000000..8ed89e7 --- /dev/null +++ b/crates/core/src/game/install/instance/register.rs @@ -0,0 +1,150 @@ +//! 实例登记:把「用户点了一下导入/安装」变成 `profiles.json` + `config/instances/.json`。 +//! +//! 从 `install/instance.rs` 搬出来只为守 400 行硬线(M221 的 `register_with` 让那边到 430 行); +//! 搬动是纯挪,唯一新增的是 [`InstanceSettings`] 与 [`register_with`](登记时一并定隔离档/运行目录)。 +//! +//! # 幂等 +//! +//! 同一 `version_id` + 同一展示名重复登记:复用已有档案(`created = false`),不产生第二个实例、 +//! 不改已有实例的 id(id 一旦写进档案就是稳定的)。 +//! +//! # 写盘顺序(M221) +//! +//! **先写实例设置、后写档案**。中断时留下的是「没有档案的孤立设置文件」——不可见、下次登记同一 id +//! 会覆盖;反过来(先档案后设置)会留下一个按全局档启动的实例,而全局缺省是 `isolated`, +//! 那会把 `mods/saves/config` 造进 `versions//`(登记别人的版本时那正是别人的目录)。 + +use std::path::PathBuf; + +use crate::game::error::GameError; +use crate::game::profiles::InstanceProfile; +use crate::platform::layout::LauncherLayout; + +use super::{ + instance_config_path, read_profiles, write_instance_config, write_profiles, InstanceConfig, + Isolation, +}; + +/// 一次登记的结果。 +#[derive(Debug, Clone)] +pub struct Registration { + /// 档案(新建或复用)。 + pub profile: InstanceProfile, + /// 每实例设置文件路径。 + pub config_path: PathBuf, + /// 是否新建(`false` = 已有同版本同名的实例,直接复用)。 + pub created: bool, +} + +/// 登记时一并落进 `config/instances/.json` 的初始设置。 +/// +/// 为什么要有它:登记与「运行目录怎么定」原本是两件事,于是调用方只能先 `register` 再补写设置 +/// ——中间那段时间实例是 `inherit`(跟随全局 `isolated`)。把选择前移到登记这一步,就没有那个窗口。 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct InstanceSettings { + /// 隔离档;`None` = 跟随全局(不落盘)。 + pub isolation: Option, + /// 自定义运行目录(仅 `custom` 档需要)。 + pub game_dir: Option, +} + +impl InstanceSettings { + /// 跟随全局(= 未引入 [`InstanceSettings`] 前的历史行为,不落任何设置键)。 + #[must_use] + pub fn inherit() -> Self { + Self::default() + } + + /// 是否什么都没选。 + #[must_use] + pub fn is_empty(&self) -> bool { + self.isolation.is_none() && self.game_dir.is_none() + } +} + +/// 登记一个实例(幂等,跟随全局隔离档)。 +/// +/// # Errors +/// +/// 读/写 `profiles.json`、实例 id 不合法、原子写失败。 +pub fn register( + layout: &LauncherLayout, + name: &str, + version_id: &str, + created_at: Option<&str>, +) -> Result { + register_with( + layout, + name, + version_id, + created_at, + &InstanceSettings::inherit(), + ) +} + +/// 同 [`register`],但把隔离档与运行目录**一并**写进新建实例的设置文件。 +/// +/// 复用已有档案(同版本 + 同名)时 `settings` 只在「设置文件缺失、需要补建」时生效—— +/// 已有设置文件绝不改写(那是用户的显式选择,登记不该覆盖它)。 +/// +/// # Errors +/// +/// 同 [`register`]。 +pub fn register_with( + layout: &LauncherLayout, + name: &str, + version_id: &str, + created_at: Option<&str>, + settings: &InstanceSettings, +) -> Result { + let mut profiles = read_profiles(layout)?; + let existing = profiles + .profiles + .iter() + .find(|profile| profile.version_id == version_id && profile.name == name) + .cloned(); + + if let Some(profile) = existing { + let config_path = instance_config_path(layout, profile.id.as_str())?; + // 老档案可能还没有设置文件(早期 CLI 只写 profiles.json),这里补齐。 + if !config_path.is_file() { + let mut config = InstanceConfig::new(profile.id.as_str(), version_id); + apply_settings(&mut config, settings); + write_instance_config(layout, &config)?; + } + return Ok(Registration { + profile, + config_path, + created: false, + }); + } + + let mut profile = InstanceProfile::new(name, version_id); + profile.created_at = created_at.map(str::to_string); + let id = profile.id.as_str().to_string(); + let mut config = InstanceConfig::new(id, version_id); + apply_settings(&mut config, settings); + let config_path = write_instance_config(layout, &config)?; + + profiles.upsert(profile.clone()); + if let Err(error) = write_profiles(layout, &profiles) { + // 档案没落盘 ⇒ 撤掉刚写的设置文件,不留半登记状态。 + let _ = std::fs::remove_file(&config_path); + return Err(error); + } + Ok(Registration { + profile, + config_path, + created: true, + }) +} + +/// 把登记时的初始设置贴到实例设置上(只动这两个键,其余保持稀疏缺省)。 +fn apply_settings(config: &mut InstanceConfig, settings: &InstanceSettings) { + if let Some(isolation) = settings.isolation { + config.isolation = Some(isolation); + } + if settings.game_dir.is_some() { + config.game_dir = settings.game_dir.clone(); + } +} diff --git a/crates/core/src/game/mod.rs b/crates/core/src/game/mod.rs index 7ada9b5..53e4325 100644 --- a/crates/core/src/game/mod.rs +++ b/crates/core/src/game/mod.rs @@ -24,9 +24,14 @@ //! Java 管理(M33,[`java`]):托管 JDK 的查询/下载/解压/探测(`.ULL/data/java-managed/`) //! 与 MC↔Java 映射表。同样只借 `core::download` 的引擎做下载,自己不拼镜像 URL。 //! +//! 已铺在盘上的版本接入(M221,[`adopt`]):扫描/检查 `versions/` 里**别人装的**版本 → 只登记 +//! (`profiles.json` + 实例设置),不搬文件、不写游戏根。缺省运行目录落 ULL 数据区, +//! 共用/写进对方的 `.minecraft` 必须显式选择(见 [`adopt::AdoptTarget`])。 +//! //! 模块分工按"数据形状 / 纯计算 / 状态"切开,任何模块都不碰网络与 UI: //! 下载归 `core::download`、进程归 `core::launch`、配置落盘归 `core::setting`。 +pub mod adopt; pub mod arguments; pub mod capability; pub mod error; @@ -44,6 +49,10 @@ pub mod snapshot; pub mod unlisted; pub mod version; +pub use adopt::{ + AdoptCandidate, AdoptError, AdoptOutcome, AdoptPlan, AdoptProblem, AdoptRequest, AdoptScan, + AdoptTarget, RegisteredInstance, +}; pub use arguments::{rules_allow, Arguments, Environment, ExpandedArguments, Rule, RuleAction}; pub use capability::{ is_vulkan_library, library_names_have_vulkan, VersionCapabilities, VULKAN_LIBRARY_ARTIFACT, diff --git a/crates/core/tests/adopt/fixture.rs b/crates/core/tests/adopt/fixture.rs new file mode 100644 index 0000000..da07224 --- /dev/null +++ b/crates/core/tests/adopt/fixture.rs @@ -0,0 +1,292 @@ +//! 真实磁盘夹具:一份「别的启动器铺好的」`.minecraft` + 全量快照工具。 +//! +//! # 为什么必须是真盘 +//! +//! 本 mission 的核心判据是**「登记前后外部文件一个字节都不变」**。内存里的假目录证明不了这件事 +//! ——mtime、目录项、符号链接都只有真文件系统才存在。所以每个用例都在 `std::env::temp_dir()` +//! 下建一棵真目录树,`Drop` 时整棵删掉。 +//! +//! # 夹具里放了什么(逐项都有用意) +//! +//! | 路径 | 用意 | +//! | --- | --- | +//! | `versions/1.20.1/` | 正常版本(清单 + jar) | +//! | `versions/1.20.1-fabric-0.15.0/` | 正常版本,`inheritsFrom` 指向 1.20.1(父链闭合) | +//! | `versions/1.20.1-broken/` | 清单不是合法 JSON | +//! | `versions/1.20.1-orphan/` | `inheritsFrom` 指向不存在的父 | +//! | `versions/cycle-a/` `versions/cycle-b/` | 互相继承(成环) | +//! | `versions/1.20.1-nomanifest/` | 只有目录、没有同名清单 | +//! | `libraries/` `assets/` | 恒共享层(登记不该碰) | +//! | `mods/` `saves/` `config/` | 玩家数据(登记不该碰) | +//! | `launcher_profiles.json` | 别的启动器的元数据(连读都不该读,更不该写) | + +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; +use unlucky_core::platform::layout::LauncherLayout; + +/// 唯一临时目录守卫(`drop` 时递归删除)。 +pub struct TempRoot { + path: PathBuf, +} + +impl TempRoot { + /// 建一个唯一临时根。 + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "ull-adopt-{tag}-{}-{nanos}-{seq}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("临时根应可创建"); + Self { path } + } + + /// 临时根路径。 + pub fn path(&self) -> &Path { + &self.path + } + + /// `.minecraft`(游戏根)。 + pub fn game_root(&self) -> PathBuf { + self.path.join(".minecraft") + } + + /// 游戏布局。 + pub fn game(&self) -> GameLayout { + GameLayout::new(self.game_root()) + } + + /// 启动器数据布局(`/.ULL`)。 + pub fn launcher(&self) -> LauncherLayout { + LauncherLayout::new(self.path.join(".ULL")) + } +} + +impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// 造一份「别的启动器已经铺好的」`.minecraft`(见模块文档的表)。 +pub fn foreign_minecraft(root: &Path) { + let versions = root.join("versions"); + // 正常版本:清单 + jar。 + version_with(&versions, "1.20.1", MANIFEST_VANILLA, true); + // 正常版本:带父链(fabric 形态:只有清单、没有自己的 jar)。 + version_with(&versions, "1.20.1-fabric-0.15.0", MANIFEST_FABRIC, false); + // 清单坏掉。 + let broken = versions.join("1.20.1-broken"); + std::fs::create_dir_all(&broken).expect("建坏清单目录"); + std::fs::write(broken.join("1.20.1-broken.json"), b"{ this is not json").expect("写坏清单"); + // 缺父版本。 + version_with(&versions, "1.20.1-orphan", MANIFEST_ORPHAN, true); + // 成环。 + version_with(&versions, "cycle-a", MANIFEST_CYCLE_A, true); + version_with(&versions, "cycle-b", MANIFEST_CYCLE_B, true); + // 只有目录、没有清单。 + std::fs::create_dir_all(versions.join("1.20.1-nomanifest")).expect("建无清单目录"); + + // 恒共享层:库与资源(登记一个字节都不该动)。 + write_file( + &root.join("libraries/com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar"), + b"library-bytes", + ); + write_file(&root.join("assets/indexes/5.json"), b"{\"objects\":{}}"); + write_file( + &root.join("assets/objects/ab/abcdef0123456789abcdef0123456789abcdef01"), + b"asset-bytes", + ); + // 玩家数据。 + write_file(&root.join("mods/jei.jar"), b"mod-bytes"); + write_file(&root.join("saves/World/level.dat"), b"save-bytes"); + write_file(&root.join("config/example.toml"), b"config = 1\n"); + // 别的启动器的元数据(登记不该读、更不该写)。 + write_file( + &root.join("launcher_profiles.json"), + br#"{"profiles":{"1.20.1":{"name":"1.20.1","lastVersionId":"1.20.1"}}}"#, + ); +} + +/// 一层文件的**全量**快照:相对路径 → (字节数, 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("建版本目录"); + std::fs::write(dir.join(format!("{id}.json")), manifest.as_bytes()).expect("写清单"); + if jar { + std::fs::write(dir.join(format!("{id}.jar")), b"jar-bytes").expect("写 jar"); + } +} + +fn write_file(path: &Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("建父目录"); + } + std::fs::write(path, bytes).expect("写文件"); +} + +/// 原版形态的最小清单(`downloads.client` 有值、无继承)。 +const MANIFEST_VANILLA: &str = r#"{ + "id": "1.20.1", + "type": "release", + "mainClass": "net.minecraft.client.main.Main", + "assets": "5", + "assetIndex": { "id": "5" }, + "arguments": { + "game": ["--username", "${auth_player_name}", "--version", "${version_name}", + "--gameDir", "${game_directory}"], + "jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"] + }, + "libraries": [], + "downloads": { "client": { "size": 9, "url": "http://example.invalid/client.jar" } } +}"#; + +/// Fabric 形态:`inheritsFrom` 指 1.20.1(父链闭合)。 +const MANIFEST_FABRIC: &str = r#"{ + "id": "1.20.1-fabric-0.15.0", + "type": "release", + "inheritsFrom": "1.20.1", + "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "libraries": [] +}"#; + +/// 缺父版本(父 id 在盘上不存在)。 +const MANIFEST_ORPHAN: &str = r#"{ + "id": "1.20.1-orphan", + "type": "release", + "inheritsFrom": "1.20.1-not-on-disk", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [] +}"#; + +/// 环的一侧。 +const MANIFEST_CYCLE_A: &str = r#"{ + "id": "cycle-a", + "type": "release", + "inheritsFrom": "cycle-b", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [] +}"#; + +/// 环的另一侧。 +const MANIFEST_CYCLE_B: &str = r#"{ + "id": "cycle-b", + "type": "release", + "inheritsFrom": "cycle-a", + "mainClass": "net.minecraft.client.main.Main", + "libraries": [] +}"#; + +/// 用 `mklink /J` 建目录联接(junction 不需要管理员权限;建不出来返回 `false`)。 +/// +/// 只在 Windows 上有意义:junction 不是 `is_symlink`,是把别人版本目录挂进 `versions/` 最常见的形态, +/// 所以越界判据必须能看见它——这条夹具就是用来证明这点的。 +#[cfg(windows)] +pub fn try_junction(target: &Path, link: &Path) -> bool { + // `cmd /c mklink /J `:三个参数分开传,别拼成一整条(拼成一串会被 cmd 当语法错)。 + let output = std::process::Command::new("cmd") + .args(["/c", "mklink", "/J"]) + .arg(link) + .arg(target) + .output(); + match output { + Ok(output) if output.status.success() && link.is_dir() => true, + Ok(output) => { + eprintln!( + "[junction] mklink /J {} {} 失败({:?}):{}{}", + link.display(), + target.display(), + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + false + } + Err(error) => { + eprintln!("[junction] 调不起 cmd:{error}"); + false + } + } +} + +/// 一次登记请求(测试用:固定 `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()), + } +} diff --git a/crates/core/tests/adopt/main.rs b/crates/core/tests/adopt/main.rs new file mode 100644 index 0000000..b318400 --- /dev/null +++ b/crates/core/tests/adopt/main.rs @@ -0,0 +1,16 @@ +//! M221 core 侧集成测试入口:从**别人的** `.minecraft` 只读登记版本(adopt)。 +//! +//! 分工: +//! - `fixture`:临时真实磁盘目录 + 一份「别的启动器铺好的」`.minecraft` + 全量快照工具; +//! - `scan_cases`:`scan` / `inspect` 的判据(正常 / 坏 JSON / 缺父 / 成环 / 缺清单 / 越界符号链接); +//! - `plan_cases`:还没登记时的**只读**判据与拒绝理由(dry-run 零写盘 / 缺父 / 不存在 / 非法目标); +//! - `register_cases`:`adopt` 的落点与写盘面(缺省独立目录 / shared / isolated / custom / +//! 重名 / 重复 / 非空目录 / 数据区联接 / 外部文件哈希与路径集合不变)。 +//! +//! 这些用例**不启动真实 Minecraft**:验的是「登记这一步做了什么、没做什么」,启动链路由 +//! `tests/game/*` 与 CLI 的 `launch --dry-run` 各自覆盖。 + +mod fixture; +mod plan_cases; +mod register_cases; +mod scan_cases; diff --git a/crates/core/tests/adopt/plan_cases.rs b/crates/core/tests/adopt/plan_cases.rs new file mode 100644 index 0000000..4062352 --- /dev/null +++ b/crates/core/tests/adopt/plan_cases.rs @@ -0,0 +1,112 @@ +//! `plan` / `inspect` 的**只读**判据与拒绝理由:零写盘、缺父/不存在/非法目标的报错形状。 +//! +//! 与 `register_cases` 的分工:那边验「登记写出了什么」,这边验「还没登记时算出了什么、拒绝得对不对」。 + +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}; + +/// 缺省档位本身就是「数据区独立目录」,而且**不是** `isolated`(判据的单一真相源)。 +/// +/// 抓得住:把 `#[default]` 挪到 `VersionDir`/`Shared`(第一条断言红),或让缺省档映射到 +/// `Isolated`(第二条断言红)——后者正是「导入别人的版本后第一次启动就在他的 `versions//` +/// 里长 mods/saves」的那个坑。 +/// 抓不住:CLI 是否真的用 `AdoptTarget::default()`(那条由 cli/adopt_test 的行为断言钉着)。 +#[test] +fn the_default_target_is_an_own_dir_not_the_global_isolated_mode() { + let target = AdoptTarget::default(); + assert_eq!(target, AdoptTarget::InstanceDir); + assert_eq!(target.as_str(), "instance_dir"); + assert_eq!(target.isolation(), Isolation::Custom); + assert_ne!(target.isolation(), Isolation::Isolated); +} + +/// `--isolation custom --game-dir <相对路径>` ⇒ 明确报错(不猜、不悄悄改成绝对路径)。 +/// +/// 抓得住:相对路径被默默接受(`TargetInvalid` 变成成功)。 +/// 抓不住:Windows 上 `C:foo` 这种「带盘符但非绝对」的形状,只看 `is_absolute`。 +#[test] +fn custom_target_requires_an_absolute_directory() { + let root = TempRoot::new("custom-rel"); + foreign_minecraft(&root.game_root()); + let error = adopt::adopt( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::Custom(PathBuf::from("relative/dir"))), + ) + .expect_err("相对路径应被拒"); + assert_eq!(error.code(), "adopt_target_invalid"); + assert!(error.to_string().contains("绝对路径"), "{error}"); +} + +/// `--dry-run` 的 core 形态(`plan`):一个文件都不写、目录也不建。 +/// +/// 抓得住:`plan` 顺手建了运行目录或写了档案(两种断言都会红)。 +/// 抓不住:CLI 的 `--dry-run` 是否真的只调 `plan`(那条在 cli 侧用例里)。 +#[test] +fn plan_writes_nothing_at_all() { + let root = TempRoot::new("plan"); + foreign_minecraft(&root.game_root()); + let before = snapshot(root.path()); + + let plan = adopt::plan( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::InstanceDir), + ) + .expect("计划应成功"); + let after = snapshot(root.path()); + assert_unchanged(&before, &after, "plan 之后(含 .ULL)"); + + assert!(plan.candidate.adoptable()); + assert!(!plan.run_dir.exists(), "plan 不该建目录"); + assert!(!root.path().join(".ULL").exists(), "plan 不该建配置目录"); +} + +/// 不可登记的版本(缺父)⇒ 一条都不登记,问题清单原样带出。 +/// +/// 抓得住:缺父的版本被登记(启动必失败却显示「导入成功」)、问题被折叠成一句空话。 +/// 抓不住:用户在原来的启动器里修好之后重试(那条由重试路径自然覆盖)。 +#[test] +fn not_adoptable_versions_are_refused_with_their_reasons() { + let root = TempRoot::new("refuse"); + foreign_minecraft(&root.game_root()); + let error = adopt::adopt( + &root.game(), + &root.launcher(), + &request("1.20.1-orphan", AdoptTarget::InstanceDir), + ) + .expect_err("缺父版本应被拒"); + assert_eq!(error.code(), "adopt_not_adoptable"); + let codes: Vec<&str> = error + .problems() + .iter() + .map(|problem| problem.code()) + .collect(); + assert_eq!(codes, vec!["missing_parent"]); + assert!( + !root.path().join(".ULL/config/profiles.json").exists(), + "拒绝时不许留档案" + ); +} + +/// 不存在的版本 ⇒ `adopt_version_not_found`(不是「帮你在网上下一个」)。 +/// +/// 抓得住:不存在的 id 被当成可登记(档案指向空版本)。 +/// 抓不住:`versions/` 整个不存在的情形(上一条扫描用例覆盖)。 +#[test] +fn an_unknown_version_is_reported_as_not_found() { + let root = TempRoot::new("unknown"); + foreign_minecraft(&root.game_root()); + let error = adopt::adopt( + &root.game(), + &root.launcher(), + &request("9.9.9", AdoptTarget::InstanceDir), + ) + .expect_err("不存在的版本应报错"); + assert!(matches!(error, AdoptError::UnknownVersion { .. })); + assert_eq!(error.code(), "adopt_version_not_found"); +} diff --git a/crates/core/tests/adopt/register_cases.rs b/crates/core/tests/adopt/register_cases.rs new file mode 100644 index 0000000..adcffd4 --- /dev/null +++ b/crates/core/tests/adopt/register_cases.rs @@ -0,0 +1,300 @@ +//! `plan` / `adopt` 的落点与写盘面:登记只写自己的配置,外部 `.minecraft` 一个字节都不动。 +//! +//! 每条用例都带「抓得住 / 抓不住」,并且**外部快照断言**是每条的核心:`versions/` +//! `libraries/` `assets/` `mods/` `saves/` `config/` `launcher_profiles.json` 的路径集合、 +//! 字节数、内容哈希、mtime 全都不许变(`fixture::assert_unchanged`)。 + +use unlucky_core::game::adopt::{self, AdoptTarget}; +use unlucky_core::game::install::instance::{read_instance_config, read_profiles, Isolation}; +use unlucky_core::game::isolation::{derive_game_dir, IsolationMode}; + +use crate::fixture::{assert_unchanged, foreign_minecraft, request, snapshot, TempRoot}; + +/// 缺省(不给 `--isolation`):运行目录落 ULL 数据区的独立目录,隔离档写 `custom`。 +/// +/// 抓得住:缺省沿用全局 `isolated`(那样 `run_dir` 会变成 `versions/1.20.1`)、 +/// `gameDir` 没落盘、外部文件被写(哈希/mtime 断言)、profiles 没登记。 +/// 抓不住:真实启动是否真的把 `--gameDir` 用上(core 侧只验派生结果,CLI 侧用 `launch --dry-run` 验)。 +#[test] +fn default_target_lands_in_the_launcher_data_area() { + let root = TempRoot::new("default"); + foreign_minecraft(&root.game_root()); + let before = snapshot(&root.game_root()); + + let outcome = adopt::adopt( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::InstanceDir), + ) + .expect("登记应成功"); + let after = snapshot(&root.game_root()); + assert_unchanged(&before, &after, "缺省登记之后"); + + assert_eq!(outcome.isolation, Isolation::Custom); + assert!(outcome.created); + assert!(outcome.dir_created, "缺省目录应被新建"); + let instances_dir = root.path().join(".ULL/data/instances"); + assert!( + outcome.run_dir.starts_with(&instances_dir), + "运行目录必须在 ULL 数据区:{}", + outcome.run_dir.display() + ); + assert!(outcome.run_dir.is_dir()); + assert!( + !outcome.run_dir.starts_with(root.game_root()), + "缺省运行目录不许落在别人的 .minecraft 里" + ); + + // 落盘的三样东西:profiles.json、实例设置、新建的空运行目录。 + let profiles = read_profiles(&root.launcher()).expect("读档案"); + assert_eq!(profiles.profiles.len(), 1); + assert_eq!(profiles.profiles[0].name, "1.20.1"); + assert_eq!(profiles.profiles[0].version_id, "1.20.1"); + let config = read_instance_config(&root.launcher(), &outcome.instance_id) + .expect("读设置") + .expect("设置文件存在"); + assert_eq!(config.isolation, Some(Isolation::Custom)); + assert_eq!(config.game_dir.as_deref(), Some(outcome.run_dir.as_path())); + + // 启动时 `--gameDir` 真的会取这个目录(D20 覆盖链的权威求值)。 + let derivation = derive_game_dir( + &root.game(), + "1.20.1", + IsolationMode::Isolated, + Some(&config), + None, + ) + .expect("派生运行目录"); + assert_eq!(derivation.game_dir, outcome.run_dir); + assert_eq!(derivation.isolation, Isolation::Custom); +} + +/// `--isolation shared`:运行目录 = 别人的游戏根,且**人读提示必须说出后果**。 +/// +/// 抓得住:shared 档被当成 custom 写 `game_dir`、提示里没提「共用/别人的 .minecraft」、 +/// 登记过程往游戏根写了东西(哈希断言)。 +/// 抓不住:用户是否真的读到了这句提示(那属于 UI 验收)。 +#[test] +fn shared_target_points_at_the_game_root_and_says_what_it_means() { + let root = TempRoot::new("shared"); + foreign_minecraft(&root.game_root()); + let before = snapshot(&root.game_root()); + + let outcome = adopt::adopt( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::Shared), + ) + .expect("登记应成功"); + let after = snapshot(&root.game_root()); + assert_unchanged(&before, &after, "shared 登记之后"); + + assert_eq!(outcome.isolation, Isolation::Shared); + assert_eq!(outcome.run_dir, root.game_root()); + assert!( + outcome.notes.iter().any(|note| note.contains("共用")), + "必须告知「共用别人的 .minecraft」:{:?}", + outcome.notes + ); + let config = read_instance_config(&root.launcher(), &outcome.instance_id) + .expect("读设置") + .expect("设置文件存在"); + assert_eq!(config.isolation, Some(Isolation::Shared)); + assert_eq!(config.game_dir, None, "shared 档不该固化成 custom"); +} + +/// `--isolation isolated`:运行目录 = `versions/`,**登记这一刻仍不往那里写任何东西**。 +/// +/// 抓得住:`VersionDir` 被漏做(`isolation` 不是 `Isolated`)、提示里没提「会新增玩家数据」、 +/// 登记当场就在版本目录里建了 `mods/`(下面的目录内容断言会红)。 +/// 抓不住:首次启动之后游戏自己往里写(那是启动链路的事,本 mission 不跑真游戏)。 +#[test] +fn version_dir_target_is_explicit_and_writes_nothing_yet() { + let root = TempRoot::new("isolated"); + foreign_minecraft(&root.game_root()); + let before = snapshot(&root.game_root()); + + let outcome = adopt::adopt( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::VersionDir), + ) + .expect("登记应成功"); + let after = snapshot(&root.game_root()); + assert_unchanged(&before, &after, "isolated 登记之后"); + + assert_eq!(outcome.isolation, Isolation::Isolated); + assert_eq!(outcome.run_dir, root.game_root().join("versions/1.20.1")); + assert!( + outcome.notes.iter().any(|note| note.contains("新增")), + "必须告知「第一次启动会在这里新增玩家数据」:{:?}", + outcome.notes + ); + assert!(!outcome.dir_created, "版本目录已存在,不该新建"); + let mut entries: Vec = std::fs::read_dir(&outcome.run_dir) + .expect("读版本目录") + .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned())) + .collect(); + entries.sort(); + assert_eq!( + entries, + vec!["1.20.1.jar".to_string(), "1.20.1.json".to_string()], + "版本目录里不该多出任何东西" + ); +} + +/// 展示名已被别的实例占用 ⇒ 显式报错,不改任何档案(不静默复用别的版本)。 +/// +/// 抓得住:`register` 的幂等复用路径被当成「导入成功」(那样第二个版本会指向第一个实例的档案)。 +/// 抓不住:CLI 的 hint 文案(那条在 cli 侧用例里)。 +#[test] +fn a_taken_name_is_an_explicit_error() { + let root = TempRoot::new("dupname"); + foreign_minecraft(&root.game_root()); + adopt::adopt( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::InstanceDir), + ) + .expect("第一个登记成功"); + let profiles_before = + std::fs::read(root.path().join(".ULL/config/profiles.json")).expect("读档案"); + + let mut second = request("1.20.1-fabric-0.15.0", AdoptTarget::InstanceDir); + second.name = Some("1.20.1".to_string()); + let error = adopt::adopt(&root.game(), &root.launcher(), &second).expect_err("重名应被拒"); + assert_eq!(error.code(), "adopt_duplicate_name"); + let profiles_after = + std::fs::read(root.path().join(".ULL/config/profiles.json")).expect("读档案"); + assert_eq!(profiles_before, profiles_after, "失败不许改档案"); +} + +/// 同一个版本重复登记 ⇒ 无副作用失败(`adopt_already_registered`),档案逐字节不变。 +/// +/// 抓得住:重复登记变成「静默成功」(第二条断言会红),或失败时仍改写了档案。 +/// 抓不住:并发两次 adopt 的竞态(没有锁;本命令不是并发入口,如实记在这里)。 +#[test] +fn repeating_the_same_adopt_fails_without_side_effects() { + let root = TempRoot::new("repeat"); + foreign_minecraft(&root.game_root()); + adopt::adopt( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::InstanceDir), + ) + .expect("第一次登记成功"); + let config_dir = root.path().join(".ULL/config"); + let before = snapshot(&config_dir); + + let error = adopt::adopt( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::InstanceDir), + ) + .expect_err("重复登记应报错"); + assert_eq!(error.code(), "adopt_already_registered"); + let after = snapshot(&config_dir); + assert_unchanged(&before, &after, "重复登记失败之后"); +} + +/// 缺省运行目录已存在且非空 ⇒ 拒绝(不猜「合并进已有实例」,不覆盖用户内容)。 +/// +/// 抓得住:非空目录被复用(内容被游戏写进去只是时间问题)、拒绝时仍写了档案。 +/// 抓不住:空目录复用那条正向路径(见下一条用例)。 +#[test] +fn a_non_empty_default_run_dir_is_refused() { + let root = TempRoot::new("occupied"); + foreign_minecraft(&root.game_root()); + let plan = adopt::plan( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::InstanceDir), + ) + .expect("先算一次落点"); + std::fs::create_dir_all(&plan.run_dir).expect("建目录"); + std::fs::write(plan.run_dir.join("someone-elses-file.txt"), b"keep me").expect("写文件"); + + let error = adopt::adopt( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::InstanceDir), + ) + .expect_err("非空目录应被拒"); + assert_eq!(error.code(), "adopt_target_not_empty"); + assert_eq!( + std::fs::read(plan.run_dir.join("someone-elses-file.txt")).expect("读文件"), + b"keep me" + ); + assert!( + !root.path().join(".ULL/config/profiles.json").exists(), + "拒绝时不许留档案" + ); +} + +/// 缺省运行目录存在但**为空** ⇒ 复用(上次失败的残留不该让用户永远重试不了)。 +/// +/// 抓得住:空目录也被拒(那就把「先建目录再登记」这条路堵死了)。 +/// 抓不住:目录里的隐藏文件(`read_dir` 能看见,但平台差异在这里没覆盖)。 +#[test] +fn an_empty_default_run_dir_is_reused() { + let root = TempRoot::new("empty-dir"); + foreign_minecraft(&root.game_root()); + let plan = adopt::plan( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::InstanceDir), + ) + .expect("先算一次落点"); + std::fs::create_dir_all(&plan.run_dir).expect("建空目录"); + + let outcome = adopt::adopt( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::InstanceDir), + ) + .expect("空目录可复用"); + assert!(!outcome.dir_created, "已存在的空目录不算新建"); + assert_eq!(outcome.run_dir, plan.run_dir); +} + +/// 数据区里预先放了指向外部的目录联接 ⇒ 缺省运行目录拒绝沿它写盘。 +/// +/// 抓得住:`create_dir_all` 直接沿联接在数据根之外建目录(外部会多出条目,最后一条断言红), +/// 或错误码不是 `adopt_target_invalid`。 +/// 抓不住:数据根**自己**是链接的情形——那是用户的数据目录迁移手法,允许(判据是落点仍在 +/// 解析后的数据根内);也抓不住检查与建目录之间的 TOCTOU。 +#[cfg(windows)] +#[test] +fn a_junction_inside_the_data_area_does_not_get_written_through() { + let root = TempRoot::new("datajunction"); + foreign_minecraft(&root.game_root()); + let outside = root.path().join("outside-instances"); + std::fs::create_dir_all(&outside).expect("建外部目录"); + // 逐段 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 失败),这条用例没有跑" + ); + return; + } + + let error = adopt::adopt( + &root.game(), + &root.launcher(), + &request("1.20.1", AdoptTarget::InstanceDir), + ) + .expect_err("数据区里的联接必须被拒"); + assert_eq!(error.code(), "adopt_target_invalid", "{error}"); + let entries: Vec = std::fs::read_dir(&outside) + .expect("读外部目录") + .filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned())) + .collect(); + assert!( + entries.is_empty(), + "不许沿联接在数据根之外建目录:{entries:?}" + ); +} diff --git a/crates/core/tests/adopt/scan_cases.rs b/crates/core/tests/adopt/scan_cases.rs new file mode 100644 index 0000000..21ef436 --- /dev/null +++ b/crates/core/tests/adopt/scan_cases.rs @@ -0,0 +1,364 @@ +//! `scan` / `inspect` 的判据:正常版本能不能过、坏东西是不是**逐条**说清楚。 +//! +//! 每条断言下面都标了「抓得住 / 抓不住」:抓得住的是变异实现会变红的具体结论, +//! 抓不住的是只能靠人读的东西(措辞、是否还有别的静默吞掉路径)。 + +use std::path::Path; + +use unlucky_core::game::adopt::{self, AdoptCandidate}; + +use crate::fixture::{assert_unchanged, foreign_minecraft, snapshot, TempRoot}; + +/// 扫一次并按 id 取候选(`inspect` 对不合格候选返回 `Err`,扫描则逐项列出,断言更好写)。 +fn candidate_of(root: &TempRoot, version_id: &str) -> AdoptCandidate { + let scan = adopt::scan(&root.game(), &root.launcher()).expect("扫描应成功"); + scan.candidates + .into_iter() + .find(|candidate| candidate.version_id == version_id) + .unwrap_or_else(|| panic!("扫描里没有 {version_id}")) +} + +/// 正常 / 缺清单 / 坏 JSON / 缺父 / 成环:一次扫描把每个候选的结论都说出来。 +/// +/// 抓得住:`scan` 把坏东西当空列表(`candidates` 少项就红)、漏掉父链(`inherits` 长度变)、 +/// 缺父被当成可登记(`adoptable` 变真)、成环没被检出(`cycle-*` 会变可登记)。 +/// 抓不住:问题文案的措辞,以及 `versions/` 之外的地方有没有被读写(由下面的哈希断言覆盖)。 +#[test] +fn scan_reports_every_version_with_its_reason() { + let root = TempRoot::new("scan"); + foreign_minecraft(&root.game_root()); + let before = snapshot(root.path()); + + let scan = adopt::scan(&root.game(), &root.launcher()).expect("扫描应成功"); + let after = snapshot(root.path()); + assert_unchanged(&before, &after, "scan 之后"); + + let ids: Vec<&str> = scan + .candidates + .iter() + .map(|candidate| candidate.version_id.as_str()) + .collect(); + assert_eq!( + ids, + vec![ + "1.20.1", + "1.20.1-broken", + "1.20.1-fabric-0.15.0", + "1.20.1-nomanifest", + "1.20.1-orphan", + "cycle-a", + "cycle-b", + ], + "扫描必须列出每一个目录(含不可登记的),不能少一行" + ); + + let by_id = |id: &str| { + scan.candidates + .iter() + .find(|candidate| candidate.version_id == id) + .unwrap_or_else(|| panic!("缺候选 {id}")) + }; + + let vanilla = by_id("1.20.1"); + assert!( + vanilla.adoptable(), + "正常版本应可登记:{:?}", + vanilla.problems + ); + assert!(vanilla.jar_present); + assert_eq!(vanilla.inherits, vec!["1.20.1"]); + assert!(!vanilla.is_registered()); + + let fabric = by_id("1.20.1-fabric-0.15.0"); + assert!(fabric.adoptable(), "{:?}", fabric.problems); + assert!(!fabric.jar_present, "fabric 版本自己没有 jar"); + assert_eq!( + fabric.inherits, + vec!["1.20.1-fabric-0.15.0", "1.20.1"], + "父链必须走到底" + ); + + let codes = |id: &str| -> Vec<&'static str> { + by_id(id) + .problems + .iter() + .map(|problem| problem.code()) + .collect() + }; + assert_eq!(codes("1.20.1-broken"), vec!["manifest_invalid_json"]); + assert_eq!(codes("1.20.1-nomanifest"), vec!["manifest_missing"]); + assert_eq!(codes("1.20.1-orphan"), vec!["missing_parent"]); + assert_eq!(codes("cycle-a"), vec!["circular_inheritance"]); + assert_eq!(codes("cycle-b"), vec!["circular_inheritance"]); + for id in ["1.20.1-broken", "1.20.1-orphan", "cycle-a"] { + assert!(!by_id(id).adoptable(), "{id} 不该被判成可登记"); + } +} + +/// 缺父要说清**缺哪个**、成环要说清**环长什么样**(不是一句「继承有问题」)。 +/// +/// 抓得住:`MissingParent` 丢了 `parent` 字段、环链只剩最后一个 id(两个都会被下面的 contains 抓到)。 +/// 抓不住:错误码是否被 CLI 原样透出(那条在 cli/tests/adopt_test 里)。 +#[test] +fn missing_parent_and_cycle_name_what_is_wrong() { + let root = TempRoot::new("chain"); + foreign_minecraft(&root.game_root()); + let scan = adopt::scan(&root.game(), &root.launcher()).expect("扫描应成功"); + let problems = |id: &str| { + scan.candidates + .iter() + .find(|candidate| candidate.version_id == id) + .expect("有候选") + .problems + .clone() + }; + + let orphan = problems("1.20.1-orphan"); + let message = orphan.first().expect("一条问题").to_string(); + assert!(message.contains("1.20.1-not-on-disk"), "{message}"); + assert!(message.contains("1.20.1-orphan"), "{message}"); + + let cycle = problems("cycle-a"); + let message = cycle.first().expect("一条问题").to_string(); + assert!(message.contains("cycle-a"), "{message}"); + assert!(message.contains("cycle-b"), "{message}"); +} + +/// 坏 JSON 要带**行列号**(用户才知道去改哪一行)。 +/// +/// 抓得住:把 serde 的错误文本换成一句「解析失败」(`line` 就没了)。 +/// 抓不住:具体行列号对不对(不同 serde_json 版本的措辞会变,只断言关键字)。 +#[test] +fn broken_json_message_carries_a_line_number() { + let root = TempRoot::new("badjson"); + foreign_minecraft(&root.game_root()); + let candidate = candidate_of(&root, "1.20.1-broken"); + let message = candidate.problems.first().expect("一条问题").to_string(); + assert!(message.contains("line"), "{message}"); + assert!(message.contains("1.20.1-broken"), "{message}"); +} + +/// 清单 `id` 与目录名不一致 ⇒ 拒绝(启动时会按清单 id 找 jar,必然找不到)。 +/// +/// 抓得住:去掉 `ManifestIdMismatch` 这条判据后,这个版本会变成「可登记」。 +/// 抓不住:真实启动是否真的失败(那需要真跑游戏,本 mission 不做)。 +#[test] +fn manifest_id_must_match_the_directory_name() { + let root = TempRoot::new("idmismatch"); + foreign_minecraft(&root.game_root()); + let dir = root.game_root().join("versions/1.20.1-copy"); + std::fs::create_dir_all(&dir).expect("建目录"); + std::fs::write( + dir.join("1.20.1-copy.json"), + br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#, + ) + .expect("写清单"); + std::fs::write(dir.join("1.20.1-copy.jar"), b"jar").expect("写 jar"); + + let candidate = candidate_of(&root, "1.20.1-copy"); + let codes: Vec<&str> = candidate + .problems + .iter() + .map(|problem| problem.code()) + .collect(); + assert_eq!(codes, vec!["manifest_id_mismatch"]); +} + +/// 版本 id 不能当路径用(`../` / 盘符 / 结尾点号)⇒ 一条结构化问题,不去碰盘。 +/// +/// 抓得住:删掉 id 校验后 `inspect("../escape")` 会去读游戏根之外的路径(下面断言问题码)。 +/// 抓不住:Windows 大小写不敏感之类的平台细节(与判据无关)。 +#[test] +fn version_ids_that_cannot_be_paths_are_rejected() { + let root = TempRoot::new("badid"); + foreign_minecraft(&root.game_root()); + let escape = root.path().join("outside"); + std::fs::create_dir_all(escape.join("1.20.1")).expect("建外部同名目录"); + std::fs::write( + escape.join("1.20.1/1.20.1.json"), + br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#, + ) + .expect("写外部清单"); + + // `versions/../../outside/1.20.1` 这种形状:目录真的存在(能解析出去),但 id 里含分隔符。 + let error = adopt::inspect(&root.game(), &root.launcher(), "../../outside/1.20.1") + .expect_err("非法 id 应报错,而不是去读游戏根之外的目录"); + assert_eq!(error.code(), "adopt_not_adoptable"); + let codes: Vec<&str> = error + .problems() + .iter() + .map(|problem| problem.code()) + .collect(); + assert_eq!(codes, vec!["version_id_invalid"], "{error}"); + + for bad in ["1.20.1.", "1.20.1 ", "C:", "a/b"] { + let error = + adopt::inspect(&root.game(), &root.launcher(), bad).expect_err("非法 id 应报错"); + assert!( + error + .problems() + .iter() + .any(|problem| problem.code() == "version_id_invalid"), + "{bad:?} 应被当成非法 id:{error}" + ); + } +} + +/// 版本目录是指向游戏根**之外**的符号链接 ⇒ 拒绝不跟随(也不读它)。 +/// +/// 抓得住:`ensure_inside` 不比较解析后的真实路径(那样 `1.20.1-link` 会变成「可登记」)。 +/// 抓不住 / 已知局限: +/// - 本机不允许建符号链接(Windows 未开开发者模式、或非管理员)时这条会打印 `[symlink-guard]` +/// 并**跳过**——显式记录而不是静静通过;设 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 可让它必跑(建不出来即红); +/// - 目录联接(junction)由下面单独那条用例覆盖(不需要管理员权限,是更常见的形态)。 +#[test] +fn a_symlinked_version_pointing_outside_the_game_root_is_rejected() { + let root = TempRoot::new("symlink"); + foreign_minecraft(&root.game_root()); + let outside = root.path().join("other-launcher"); + std::fs::create_dir_all(outside.join("1.20.1")).expect("建外部版本目录"); + std::fs::write( + outside.join("1.20.1/1.20.1.json"), + br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#, + ) + .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(建不出来即红)" + ); + return; + } + + let candidate = candidate_of(&root, "1.20.1-link"); + let codes: Vec<&str> = candidate + .problems + .iter() + .map(|problem| problem.code()) + .collect(); + assert!( + codes.contains(&"outside_game_root"), + "越界链接必须被点名:{codes:?}" + ); + assert!(!candidate.adoptable(), "越界链接的版本不许登记"); +} + +/// 游戏根内的符号链接(指向同一棵树里的真实版本目录)**不**拒绝:那是合法的省盘做法。 +/// +/// 抓得住:`check_symlink` 一刀切拒绝所有符号链接(这条会红)。 +/// 抓不住:本机建不出链接时同上跳过(打印 `[symlink-guard]`)。 +#[test] +fn a_symlink_inside_the_game_root_is_allowed() { + let root = TempRoot::new("symlink-in"); + foreign_minecraft(&root.game_root()); + // 真实版本目录放在根内的 `hidden/` 下(清单 id 与目录名一致),再在 `versions/` 里挂一条链接。 + let real = root.game_root().join("hidden/1.20.1-thin"); + std::fs::create_dir_all(&real).expect("建真实版本目录"); + std::fs::write( + real.join("1.20.1-thin.json"), + br#"{"id":"1.20.1-thin","type":"release","mainClass":"x","libraries":[]}"#, + ) + .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] 本机不允许建符号链接,这条用例没有跑" + ); + return; + } + let candidate = candidate_of(&root, "1.20.1-thin"); + assert!( + candidate.adoptable(), + "根内链接不该被拒:{:?}", + candidate.problems + ); +} + +/// 建目录符号链接(`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)`(下面的断言会红),或把「不存在」当错误。 +/// 抓不住:读目录失败(ENOTDIR/EACCES)那条分支——真造一个不可读目录在 Windows 上不可靠, +/// 那一条由代码审读把关。 +#[test] +fn a_missing_versions_dir_is_empty_not_an_error() { + let root = TempRoot::new("noversions"); + let scan = adopt::scan(&root.game(), &root.launcher()).expect("应成功"); + assert!(scan.candidates.is_empty()); + assert!( + !root.game_root().exists(), + "扫描不该顺手建游戏根目录:{}", + root.game_root().display() + ); +} + +/// Windows 目录联接(junction)指向根外 ⇒ **同样**拒绝,而且不去读那个目录。 +/// +/// 为什么要单列:junction 不是 `is_symlink`,只看链接类型的实现会放行它——而 junction +/// **不需要管理员权限**就能建,是把别人版本目录挂进 `versions/` 最常见的形态。 +/// +/// 抓得住: +/// - 判据退回 `is_symlink`(除 Windows junction 外全都看不到,此时候选会变成「可登记」,第一条断言红); +/// - 判据排到**解析清单之后**:根外那份清单是合法 JSON 且 id 与目录名一致,一旦走到解析, +/// `manifest_id` 就会被填上(第二条断言红)——所以它至少能证明「没解析过根外清单」; +/// - 库里没有 `mklink` 时打印 `[symlink-guard]` 并跳过(设 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 必跑)。 +/// +/// 抓不住(证据边界,别把它读大):**只 stat/只 open 不解析**的读取这里看不出来 +/// (`manifest_id` 为 `None` 只说明没解析过,不说明没碰过文件);「边界检查排在 +/// `read_manifest` 之前」这条由 `inspect.rs::check` 的代码顺序保证,靠人读。 +/// 也抓不住检查与读取之间的 TOCTOU(本模块不是并发/对抗入口)。 +#[cfg(windows)] +#[test] +fn a_junction_pointing_outside_is_rejected_without_reading_it() { + let root = TempRoot::new("junction"); + foreign_minecraft(&root.game_root()); + // 逐段 join:`cmd` / `mklink` 会把正斜杠当开关,拼成一串带 `/` 的路径会被判「无效参数」。 + let outside = root.path().join("other-launcher").join("1.20.1-junction"); + std::fs::create_dir_all(&outside).expect("建外部版本目录"); + std::fs::write( + outside.join("1.20.1-junction.json"), + br#"{"id":"1.20.1-junction","type":"release","mainClass":"x","libraries":[]}"#, + ) + .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 失败),这条用例没有跑" + ); + return; + } + + let candidate = candidate_of(&root, "1.20.1-junction"); + let codes: Vec<&str> = candidate + .problems + .iter() + .map(|problem| problem.code()) + .collect(); + assert!( + codes.contains(&"outside_game_root"), + "联接越界必须被点名:{codes:?} / {:?}", + candidate.problems + ); + assert!(!candidate.adoptable(), "越界联接的版本不许登记"); + assert!( + candidate.manifest_id.is_none() && candidate.inherits.is_empty(), + "拒绝之后不许再读根外那份清单:{:?}", + candidate.manifest_id + ); +} From 8110cde3f5808fcca59ec4debf9907f92cfb4113 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 23:13:57 +0800 Subject: [PATCH 2/3] =?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 +} From 347a3cc90d85d66016f4565deb489380c65c0a5f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 23:56:02 +0800 Subject: [PATCH 3/3] =?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}"); +}