feat(game): 从其他启动器只读登记已有版本(ull instances scan|adopt)+ 登记可一并定隔离档(M221)

盘上已经铺好的版本(多半是 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 数据区 `<data>/instances/<实例目录名>`(custom 档),绝不沿用全局
  `isolated`**——那条缺省的运行目录正是 `versions/<versionId>/`,会在他人的版本目录里长出
  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 <version-id> [--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`)。
This commit is contained in:
AskaEth committed 2026-11-09 22:21:37 +08:00
1 parent 0577ee87a3
commit 9f5c001387
25 files changed
+3460 -73

No files matched your search

+36 -1
View File
@@ -17,6 +17,10 @@ pub const fn general() -> &'static str {
instances launch-args <instance> [--jvm T] [--game T] [--memory MB] [--java P]
[--backend default|opengl|vulkan] [--clear]
读/写实例级启动参数(不带选项 = 只读;--clear = 全部回跟随全局)
instances scan 列出 versions/ 里已铺好的版本(含别的启动器装的):能不能登记 + 原因
instances adopt <version-id> [--name N] [--isolation shared|isolated|custom] [--game-dir DIR]
[--dry-run] 把已铺好的版本登记成实例(只读外部、不搬文件;缺省运行目录
落 ULL 数据区的独立目录;--dry-run 只检查不写盘)
install <version> [--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 <version-id> [--name N] [--isolation shared|isolated|custom]
[--game-dir DIR] [--dry-run]
把 versions/<id>/ 登记成实例:只读外部、只写自己的配置——不搬文件、不补清单、不改任何
外部文件的 mtime(versions/ libraries/ assets/ 一个字节都不写)。不读任何启动器的账户凭据。
运行目录(--gameDir)三档,缺省不沿用全局 isolated(那会把 mods/saves/config 造进
别人的 versions/<id>/,那是别的启动器的版本目录):
不给 --isolation → <数据根>/.ULL/data/instances/<实例目录名>(新建空目录,隔离档写 custom)
--isolation shared → 别人的游戏根(共用其 mods/saves/config;明确选择后才生效)
--isolation isolated → versions/<version-id>/(第一次启动会在这里新增玩家数据目录)
--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(
"\
+20
View File
@@ -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<String>),
/// `instances list`。
InstancesList,
/// `instances scan`:列出当前游戏根里可登记 / 不可登记的外部版本(只读,M221)。
InstancesScan,
/// `instances adopt <version-id> [--name N] [--isolation ...] [--game-dir DIR] [--dry-run]`。
InstancesAdopt(InstancesAdoptArgs),
/// `instances launch-args <instance> [--jvm …] [--game …] [--memory …] [--java …] [--backend …] [--clear]`。
InstancesLaunchArgs(InstanceLaunchArgs),
/// `install <version> [--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/<id>/`)。
pub version_id: String,
/// 展示名(缺省 = 版本 id)。
pub name: Option<String>,
/// 运行目录选择:缺省 [`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 {
+74 -5
View File
@@ -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<Command, CliError> {
@@ -12,10 +16,16 @@ pub(super) fn parse_instances(tail: &[String]) -> Result<Command, CliError> {
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<Command, CliError> {
clear: parsed.has("clear"),
}))
}
/// `instances adopt <version-id> [--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<Command, CliError> {
let parsed = parse_flags(tail, &["name", "isolation", "game-dir"], &["dry-run"])?;
let version_id = require_single_positional(&parsed, "instances adopt <version-id>")?;
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 <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"),
}))
}
+4
View File
@@ -144,6 +144,10 @@ fn loader_json(version_id: &str) -> Value {
})
}
pub mod adopt;
mod adopt_shared;
pub mod scan;
#[cfg(test)]
mod tests;
+118
View File
@@ -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 <version-id> [--name N] [--isolation …] [--game-dir DIR] [--dry-run]`。
///
/// # Errors
///
/// 版本不在盘上 / 候选不合格 / 名字冲突 / 运行目录不可用 / 读配置失败,全部由
/// [`unlucky_core::game::adopt::AdoptError`] 映射成稳定错误码(`adopt_*`)。
pub fn run(ctx: &Ctx, args: &InstancesAdoptArgs) -> Result<Report, CliError> {
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<Report, CliError> {
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))
}
@@ -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::<Vec<Value>>(),
"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 <version-id> --name <新名字>")
}
AdoptError::NotAdoptable { .. } => Some(
"这些版本要么清单坏了、要么缺父版本、要么越界:先在原来的启动器里把它修好,\
或 `%prog% install <version>` 让本启动器自己装一份",
),
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()),
}))
}
+91
View File
@@ -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<Report, CliError> {
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<serde_json::Value> = 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 <version>` 可以自己装一份"
.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 <version-id>(缺省落 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(" -> ")),
}
}
+4
View File
@@ -37,6 +37,10 @@ pub fn run(invocation: &Invocation) -> Result<Outcome, CliError> {
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))
+315
View File
@@ -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/<id>:{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` 是**脱敏后**的(主目录 → `<home>`,见 `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}");
}
+102
View File
@@ -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<&str>> = 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 <version>",
] {
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}");
}
+140
View File
@@ -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;
/// 造一份「别的启动器已经铺好的」数据根(`<root>/.minecraft` + `<root>/.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<String, (u64, u128, u64)> {
let mut out = BTreeMap::new();
walk(dir, dir, &mut out);
out
}
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, (u64, u128, u64)>) {
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<String, (u64, u128, u64)>,
after: &BTreeMap<String, (u64, u128, u64)>,
what: &str,
) {
assert_eq!(
before.keys().collect::<Vec<_>>(),
after.keys().collect::<Vec<_>>(),
"{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": []
}"#;
+54
View File
@@ -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/<id>.json`(+ 缺省运行目录的准备,若选了它)。
//! 因此「导入前后对 `versions/` `libraries/` 做全量哈希必须完全不变」是可以用测试钉死的事实。
//!
//! # 为什么要有它(现状的洞)
//!
//! [`GameLayout::installed_versions`] 早就会扫 `versions/`,但**没有任何代码把扫到的东西变成实例**。
//! 于是「别人的 `.minecraft` 已经装好了 1.20.1」这件事在 ULL 里看不见,用户只能重装一遍
//! (下载全部资源)。这个模块把那批版本变成实例档案,内容一个字节不搬。
//!
//! # 缺省隔离档**不能**沿用全局 `isolated`(本模块最要紧的一条)
//!
//! 全局 `instances.mode` 缺省是 `isolated`,而 `isolated` 的运行目录正是 `versions/<versionId>/`
//! ([`crate::game::isolation::derive_game_dir`])。若登记沿用这条缺省,第一次启动就会在
//! **别人的版本目录**里长出 `mods/` `saves/` `config/` `logs/` —— 不覆盖任何文件,但与
//! 「多启动器共存」的意图相反。所以 [`AdoptTarget`] 的缺省值是
//! [`AdoptTarget::InstanceDir`](落 ULL 数据区 `<data>/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};
+276
View File
@@ -0,0 +1,276 @@
//! 登记的**数据形状**:候选、结构化失败、运行目录选择。
//!
//! 三种形状刻意分开:
//! - [`AdoptCandidate`]:盘上的一个 `versions/<id>/` 是不是能登记的版本(含逐项问题);
//! - [`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/<id>/` 存在但没有同名清单(只剩 natives / 上个启动器的残留)。
ManifestMissing {
/// 版本 id(目录名)。
version_id: String,
/// 期望的清单路径 `versions/<id>/<id>.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<String>,
},
/// 继承链在 `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/<id>/`)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdoptCandidate {
/// 版本 id(= 目录名,登记后写进 `profiles.json` 的 `version_id`)。
pub version_id: String,
/// 版本目录 `versions/<id>/`。
pub version_dir: PathBuf,
/// 清单路径 `versions/<id>/<id>.json`。
pub manifest_path: PathBuf,
/// 客户端 jar `versions/<id>/<id>.jar` 是否存在(只报事实,不校验内容)。
pub jar_present: bool,
/// 清单里声明的 id(读不出来时为 `None`)。
pub manifest_id: Option<String>,
/// `inheritsFrom` 链(自身在前、最祖先在后;读不出来时是已走通的那一段)。
pub inherits: Vec<String>,
/// 已有实例绑定这个版本(空 = 还没登记过)。
pub registered: Vec<RegisteredInstance>,
/// 问题清单(空 = 可登记)。
pub problems: Vec<AdoptProblem>,
}
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 数据区里新建独立运行目录 `<data>/instances/<实例目录名>`(写 `custom` 档)。
#[default]
InstanceDir,
/// 共用当前游戏根(别人的 `.minecraft`)——必须显式选,且必须告知后果。
Shared,
/// 版本目录 `versions/<id>/`(`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<PathBuf>) -> Option<Self> {
match raw.trim() {
"shared" => Some(Self::Shared),
"isolated" | "per_version" => Some(Self::VersionDir),
"custom" => game_dir.map(Self::Custom),
_ => None,
}
}
}
+143
View File
@@ -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/<id>/`)。
UnknownVersion {
/// 请求的版本 id。
version_id: String,
/// 期望的目录。
version_dir: PathBuf,
},
/// 候选不合格(`problems` 就是全部原因,一条都不吞)。
NotAdoptable {
/// 版本 id。
version_id: String,
/// 全部原因。
problems: Vec<AdoptProblem>,
},
/// 展示名已被别的实例占用(**显式报错**,不静默复用一条别的版本)。
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<GameError> for AdoptError {
fn from(error: GameError) -> Self {
Self::Game(error)
}
}
+353
View File
@@ -0,0 +1,353 @@
//! 只读扫描与逐项检查:`versions/` 下哪些版本能登记、不能登记的原因是什么。
//!
//! # 三条不许偷懒的判据
//!
//! 1. **坏东西不能当空列表**:`versions/` 读不了是错误([`AdoptError::Game`]),一个版本的清单坏了是
//! 一条 [`AdoptProblem`];两条都不会变成「这里什么都没有」。
//! 2. **继承链要走到头**:缺父、成环、父清单坏了、父的清单 id 与目录名不一致,逐条报清楚是哪个文件。
//! 3. **不跟随越界符号链接**:`versions/<id>` 或清单是符号链接时,解析出的真实路径必须仍在游戏根内
//! (与导出侧「符号链接不跟随」同一政策)。
//!
//! 全程**只读**:不建目录、不写文件、不改 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<AdoptCandidate>,
}
/// 扫当前游戏根的 `versions/`,逐个检查。
///
/// `versions/` **不存在** ⇒ 空结果(新装的数据根,没有可登记的东西,不是错误);
/// `versions/` 存在但读不了 ⇒ [`AdoptError::Game`](绝不降级成空列表)。
///
/// # Errors
///
/// 读目录 / 读 `profiles.json` 失败;条目元数据读不出来(权限)时也直接失败——那属于
/// 「环境坏了」而不是「这个版本不能登记」。
pub fn scan(game: &GameLayout, launcher: &LauncherLayout) -> Result<AdoptScan, AdoptError> {
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<AdoptCandidate> = 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/<id>/` 不存在;
/// - [`AdoptError::Game`]:读 `profiles.json` 失败。
pub fn inspect(
game: &GameLayout,
launcher: &LauncherLayout,
version_id: &str,
) -> Result<AdoptCandidate, AdoptError> {
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<Vec<String>, 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<AdoptProblem> {
let manifest = read_manifest(manifest_path).ok()?;
let source = |id: &str| -> Option<VersionJson> {
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<VersionJson, AdoptProblem> {
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<RegisteredInstance> {
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(),
}],
}
}
+84
View File
@@ -0,0 +1,84 @@
//! 路径判据:版本 id 能不能当路径用、已有条目是不是跑出了游戏根(符号链接 / 目录联接 / 祖先链接)。
//!
//! 两条判据都与「登记只读」直接相关:id 不可信 ⇒ `versions/<id>/` 可能落到别人的目录上;
//! 越界 ⇒ 我们以为在游戏根里的东西其实在外面(读它就等于读别的启动器的数据)。
//! 判据写成纯函数,好在这一层单独变异。
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/<id>` 都解析到根外,
/// 单看 `<id>` 这一层还是普通目录。
///
/// 所以这里一律 `canonicalize` 后与**真实游戏根**做前缀比较:根内的链接照旧放行(合法的省盘做法),
/// 根外的一律给出 [`AdoptProblem::OutsideGameRoot`]。路径不存在 ⇒ `None`(存在性由调用方的
/// 「清单缺失」判据负责,别把「还没装」说成「越界」)。
///
/// **已知边界**:判据是「检查那一刻」的快照,检查与读取之间若有人把目录换成链接(TOCTOU),
/// 本函数看不到——本模块不是并发/对抗入口,不做跨进程锁。
pub(super) fn ensure_inside(path: &Path, root: &Path) -> Option<AdoptProblem> {
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(())
}
+324
View File
@@ -0,0 +1,324 @@
//! 落点计算与真正的登记:`plan`(纯算 + 零写盘)与 `adopt`(plan + 只写自己的两份配置)。
//!
//! # 写盘面(全部在 ULL 数据根下,游戏根一个字节都不动)
//!
//! 1. `config/instances/<id>.json`(实例设置:隔离档 + 运行目录);
//! 2. `config/profiles.json`(档案列表);
//! 3. 选了缺省运行目录时,`<data>/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/<id>/`)。
pub version_id: String,
/// 展示名;缺省 = 版本 id。
pub name: Option<String>,
/// 运行目录选择(缺省 [`AdoptTarget::InstanceDir`],**不是** `isolated`)。
pub target: AdoptTarget,
/// 档案的创建时间(RFC-3339;core 不碰时钟格式,由调用方给)。
pub created_at: Option<String>,
}
impl AdoptRequest {
/// 最小请求(缺省名 + 缺省运行目录)。
#[must_use]
pub fn new(version_id: impl Into<String>) -> 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<String>,
}
/// 一次登记的结果。
#[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<String>,
}
/// 计算一次登记:检查候选 + 解析名字 + 查重名 + 算运行目录。**零写盘**。
///
/// # 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<AdoptPlan, AdoptError> {
// `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<AdoptOutcome, AdoptError> {
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,
})
}
/// 运行目录的派生(缺省落点复用整合包导入的 `<data>/instances/<实例目录名>`)。
fn run_dir_of(
game: &GameLayout,
launcher: &LauncherLayout,
version_id: &str,
name: &str,
target: &AdoptTarget,
) -> Result<PathBuf, 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())
}
}
}
/// 运行目录选择的后果说明(人读;CLI 与 GUI 共用同一份话术)。
///
/// `shared` / `version_dir` 必须**明确告知**:它们会把玩家数据写进别人的 `.minecraft`
/// (D20 的 `isolated` 档运行目录就是 `versions/<id>/`)。
#[must_use]
pub fn target_notes(target: &AdoptTarget, run_dir: &Path) -> Vec<String> {
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 <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())
}
/// 缺省运行目录必须**真的**落在启动器数据根之下(挡住「数据区里预先放了指向外面的目录联接」)。
///
/// 边界是 `<data>`(`<root>/.ULL/data`)而不是 `<data>/instances`:后者本身可能就是链接,
/// 拿它当基准等于把「越界」当成「正常」。`<data>` 自己是链接是用户自己的迁移手法(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<PathBuf> {
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())
}
+7 -67
View File
@@ -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/<id>.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<Registration, GameError> {
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
@@ -0,0 +1,150 @@
//! 实例登记:把「用户点了一下导入/安装」变成 `profiles.json` + `config/instances/<id>.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/<versionId>/`(登记别人的版本时那正是别人的目录)。
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/<id>.json` 的初始设置。
///
/// 为什么要有它:登记与「运行目录怎么定」原本是两件事,于是调用方只能先 `register` 再补写设置
/// ——中间那段时间实例是 `inherit`(跟随全局 `isolated`)。把选择前移到登记这一步,就没有那个窗口。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct InstanceSettings {
/// 隔离档;`None` = 跟随全局(不落盘)。
pub isolation: Option<Isolation>,
/// 自定义运行目录(仅 `custom` 档需要)。
pub game_dir: Option<PathBuf>,
}
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<Registration, GameError> {
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<Registration, GameError> {
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();
}
}
+9
View File
@@ -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,
+292
View File
@@ -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())
}
/// 启动器数据布局(`<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<String, (u64, u128, u64)> {
let mut out = BTreeMap::new();
walk(dir, dir, &mut out);
out
}
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, (u64, u128, u64)>) {
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<String, (u64, u128, u64)>,
after: &BTreeMap<String, (u64, u128, u64)>,
what: &str,
) {
assert_eq!(
before.keys().collect::<Vec<_>>(),
after.keys().collect::<Vec<_>>(),
"{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 <link> <target>`:三个参数分开传,别拼成一整条(拼成一串会被 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()),
}
}
+16
View File
@@ -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;
+112
View File
@@ -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/<id>/`
/// 里长 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");
}
+300
View File
@@ -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/<id>`,**登记这一刻仍不往那里写任何东西**。
///
/// 抓得住:`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<String> = 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<String> = 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:?}"
);
}
+364
View File
@@ -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
);
}