fix(game): 复审 r1 五项——运行目录不许被两实例共用 + 配置写面越界 + versions 断链 + 链接守卫反转 + 快照漏目录
复审 p2-5items 的五项,全部按「先能红的用例,再改实现」: R1(默认目录可被不同实例隐式共用,产品缺陷):新增 `adopt/run_dir.rs`——登记前读**每个已有实例** 的 `gameDir`(现役设置 + M27 前档案的遗留字段),用「真实路径 + 拼回不存在的尾段 + 大小写归一」 (Windows/macOS)比较;撞上就顺延 `<base>-2/-3…`(上限 32,撞满 `adopt_no_free_run_dir`)并在 `notes` 点名撞的是哪个实例;**非空且无人认领**仍拒绝(那是用户数据,不偷偷换目录)。这样 `Better MC` 与 `Better-MC`(slug 同名)、Windows 上 `Foo` 与 `foo`(同目录)都不会共用一个运行目录。 大小写归一在 macOS 上是**保守**选择:大小写敏感卷上会多一次顺延,换来的是「绝不共用」在任何卷上成立。 R2(配置写面 junction 写穿到外部游戏根,产品缺陷):`run_dir::ensure_config_write_surface()` 在 `plan`(dry-run 也报)与 `adopt`(写盘前)各核一次——`config/`、`config/instances/`、 `profiles.json` 的真实落点必须在 `<.ULL>` 内,且**不得经链接落进游戏根**(错误按真实归属分类: 「逃出 .ULL」vs「落进游戏根」)。`.ULL` 本身在游戏根内是布局事实,附加判据在该布局下零判别力, 主判据仍然拦下一切跑出 `.ULL` 的落点——这不是给链接开后门。新码 `adopt_config_surface_unsafe`。 R3(dangling versions 被当不存在,产品缺陷):`scan` 的 `read_dir` `NotFound` 再三分—— `lstat(versions)` 看见入口(断链符号链接/目录联接/目标被移走)或游戏根解析失败(祖先断链)⇒ `adopt_versions_unreadable`;两者都不是才算「真没有」。`inspect` 也补了同族判据(条目存在但 不是可读目录 ⇒ `directory_unreadable`,不再混成「没装」)。 R4(强制链接测试逻辑反转,假绿):`fixture::link_gate(created, required) -> Run|Skip|Fail` 是纯 真值表,`link_guard_with()` 显式传参(`link_guard()` 只读环境变量);新增 `link_guard_executes_all_three_branches` 用 `catch_unwind` **执行** Fail/Skip 两个分支。 四处调用点统一走 guard;建链接/联接的夹具收进 `fixture.rs`。 R5(快照遗漏目录节点,假绿):新增 `tests/adopt/snapshot.rs`——全节点快照(根/目录/文件/链接 各记类型 + 适用 mtime;目录记 mtime、链接只记目标不跟随、Windows 用重解析点属性认 junction、 读盘失败直接 panic)。**CLI 测试用 `#[path]` 复用同一份**,判据只一处;新增 `fixture_cases` 五条工具自测,`plan`/dry-run 的零写盘判据改用 `snapshot_opt`(连空目录都不许出现)。 验证(本轮持久日志,`target/m221-r1/`,git 忽略、不入库): - `cargo fmt --all --check` 0;`cargo check -p unlucky-core -p unlucky-cli --all-targets` 0; - `cargo test -p unlucky-core --test adopt` **37 passed / 0 failed**(另跑一次 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 同样 37 passed); - `cargo test -p unlucky-cli --test adopt_test` **11 passed / 0 failed**; - `cargo clippy -p unlucky-core -p unlucky-cli --all-targets -- -D warnings` 0; - 四次**实现-only 变异**(缺省不查认领 / 配置写面不核 / versions 的 NotFound 当空 / 快照不记目录) 全部以**断言红**收场(不是编译错误、不是环境错误),每次还原后与基线清单逐文件一致,收尾再跑绿。 界限:只覆盖 core+cli 的定向目标(不等于 workspace 全量);没有启动真实 Minecraft;GUI 未接; 判据是检查那一刻的快照,TOCTOU 与非 junction 重解析点仍列边界。
This commit is contained in:
1 parent
9f5c001387
commit
8110cde3f5
19 files changed
+1363
-272
No files matched your search
@@ -31,6 +31,7 @@
|
||||
//! - `error`:整次调用失败([`AdoptError`],带稳定错误码);
|
||||
//! - `inspect`:只读扫描与逐项检查([`scan`] / [`inspect`]);
|
||||
//! - `paths`:路径判据(版本 id 能不能当路径、符号链接有没有跑出游戏根);
|
||||
//! - `run_dir`:运行目录派生(含**碰撞消歧**)与写盘边界(数据区 / 配置写面);
|
||||
//! - `plan`:把候选 + 用户选择算成一次登记的落点([`plan`]),真正落盘是 [`adopt`]。
|
||||
//!
|
||||
//! # 不做什么
|
||||
@@ -47,6 +48,7 @@ mod error;
|
||||
mod inspect;
|
||||
mod paths;
|
||||
mod plan;
|
||||
mod run_dir;
|
||||
|
||||
pub use candidate::{AdoptCandidate, AdoptProblem, AdoptTarget, RegisteredInstance};
|
||||
pub use error::AdoptError;
|
||||
|
||||
@@ -55,6 +55,28 @@ pub enum AdoptError {
|
||||
/// 人读原因。
|
||||
detail: String,
|
||||
},
|
||||
/// 缺省运行目录的后缀全被占用(`-2` … `-33` 都撞上了)——不无限顺延,直接报错。
|
||||
NoFreeRunDir {
|
||||
/// 派生用的基准目录。
|
||||
base: PathBuf,
|
||||
},
|
||||
/// 配置写面(`config/`、`config/instances/`、`profiles.json`)的真实落点不可信:
|
||||
/// 跑出 `<.ULL>` 或落进游戏根(符号链接 / 目录联接)。
|
||||
ConfigSurfaceUnsafe {
|
||||
/// 出问题的目标路径(`config/`、`config/instances/` 或 `profiles.json`)。
|
||||
path: PathBuf,
|
||||
/// 人读原因(含解析后的真实位置)。
|
||||
detail: String,
|
||||
},
|
||||
/// `versions` 入口存在但读不出来(断链的符号链接/目录联接、目标被移走、祖先断链)。
|
||||
///
|
||||
/// 与「`versions` 真不存在」严格区分:后者才是空结果,前者必须具名报错。
|
||||
VersionsDirUnreadable {
|
||||
/// 出问题的路径(`versions/` 或游戏根)。
|
||||
path: PathBuf,
|
||||
/// 人读原因。
|
||||
detail: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AdoptError {
|
||||
@@ -69,6 +91,9 @@ impl AdoptError {
|
||||
Self::AlreadyRegistered { .. } => "adopt_already_registered",
|
||||
Self::TargetNotEmpty { .. } => "adopt_target_not_empty",
|
||||
Self::TargetInvalid { .. } => "adopt_target_invalid",
|
||||
Self::NoFreeRunDir { .. } => "adopt_no_free_run_dir",
|
||||
Self::ConfigSurfaceUnsafe { .. } => "adopt_config_surface_unsafe",
|
||||
Self::VersionsDirUnreadable { .. } => "adopt_versions_unreadable",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +148,19 @@ impl fmt::Display for AdoptError {
|
||||
write!(f, "运行目录已存在且非空,不覆盖:{}", path.display())
|
||||
}
|
||||
Self::TargetInvalid { detail } => write!(f, "运行目录选择不成立:{detail}"),
|
||||
Self::NoFreeRunDir { base } => write!(
|
||||
f,
|
||||
"为这个实例派生独立运行目录失败:{} 及其 -2…-33 后缀都被别的实例占着",
|
||||
base.display()
|
||||
),
|
||||
Self::ConfigSurfaceUnsafe { path, detail } => {
|
||||
write!(f, "配置写面不可信:{} {detail}", path.display())
|
||||
}
|
||||
Self::VersionsDirUnreadable { path, detail } => write!(
|
||||
f,
|
||||
"versions 入口存在但读不出来 {}:{detail}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,9 @@ pub struct AdoptScan {
|
||||
|
||||
/// 扫当前游戏根的 `versions/`,逐个检查。
|
||||
///
|
||||
/// `versions/` **不存在** ⇒ 空结果(新装的数据根,没有可登记的东西,不是错误);
|
||||
/// `versions/` 存在但读不了 ⇒ [`AdoptError::Game`](绝不降级成空列表)。
|
||||
/// `versions/` **真的不存在** ⇒ 空结果(新装的数据根,没有可登记的东西,不是错误);
|
||||
/// `versions/` 入口**存在但读不出来**(断链的符号链接/目录联接、目标被移走、祖先断链)⇒
|
||||
/// [`AdoptError::VersionsDirUnreadable`],绝不降级成空列表;其它读目录失败 ⇒ [`AdoptError::Game`]。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -48,10 +49,27 @@ pub fn scan(game: &GameLayout, launcher: &LauncherLayout) -> Result<AdoptScan, A
|
||||
let entries = match std::fs::read_dir(&versions_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
// **入口还在但读不出来**不是「新数据根」:Windows 上断链的目录联接也只会给 NotFound,
|
||||
// 必须自己用不跟随链接的元数据分辨(复审 r1 的实测:lstat 成功而命令报「空」)。
|
||||
if std::fs::symlink_metadata(&versions_dir).is_ok() {
|
||||
return Err(AdoptError::VersionsDirUnreadable {
|
||||
path: versions_dir,
|
||||
detail: "入口存在但读不出来(断链的符号链接/目录联接,或目标被移走)"
|
||||
.to_owned(),
|
||||
});
|
||||
}
|
||||
// 祖先断链:游戏根本身存在却解析不出去,同样是异常而不是「还没有版本」。
|
||||
let root = game.root();
|
||||
if std::fs::symlink_metadata(root).is_ok() && root.canonicalize().is_err() {
|
||||
return Err(AdoptError::VersionsDirUnreadable {
|
||||
path: root.to_path_buf(),
|
||||
detail: "游戏根存在但解析失败(祖先链断链)".to_owned(),
|
||||
});
|
||||
}
|
||||
return Ok(AdoptScan {
|
||||
versions_dir,
|
||||
candidates: Vec::new(),
|
||||
})
|
||||
});
|
||||
}
|
||||
Err(error) => return Err(GameError::io(&versions_dir, error).into()),
|
||||
};
|
||||
@@ -108,9 +126,21 @@ pub fn inspect(
|
||||
});
|
||||
}
|
||||
if !game.version_dir(version_id).is_dir() {
|
||||
let version_dir = game.version_dir(version_id);
|
||||
// 条目存在但不是可读目录(断链链接/联接、或同名的是个文件)⇒ 具名报错,别混成「没装」。
|
||||
if std::fs::symlink_metadata(&version_dir).is_ok() {
|
||||
return Err(AdoptError::NotAdoptable {
|
||||
version_id: version_id.to_owned(),
|
||||
problems: vec![AdoptProblem::DirectoryUnreadable {
|
||||
path: version_dir,
|
||||
message: "条目存在但不是可读目录(断链 / 联接目标被移走 / 同名的是文件)"
|
||||
.to_owned(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
return Err(AdoptError::UnknownVersion {
|
||||
version_id: version_id.to_owned(),
|
||||
version_dir: game.version_dir(version_id),
|
||||
version_dir,
|
||||
});
|
||||
}
|
||||
let profiles = read_profiles(launcher)?;
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
//! 1. `config/instances/<id>.json`(实例设置:隔离档 + 运行目录);
|
||||
//! 2. `config/profiles.json`(档案列表);
|
||||
//! 3. 选了缺省运行目录时,`<data>/instances/<实例目录名>` 这个空目录(已存在且非空 ⇒ 拒绝;
|
||||
//! 路径上的已有祖先若解析到**数据根之外**(符号链接/目录联接)⇒ 拒绝,不沿它写盘)。
|
||||
//! 路径上的已有祖先若解析到**数据根之外**(符号链接/目录联接)⇒ 拒绝,不沿它写盘;
|
||||
//! 已被**别的实例**占着 ⇒ 顺延 `-2`/`-3` 后缀,绝不让两个实例共用同一个运行目录)。
|
||||
//!
|
||||
//! 写配置**之前**还要核一遍真实写面(`config/`、`config/instances/`、`profiles.json` 必须在
|
||||
//! `<.ULL>` 之内且不落进游戏根,见 [`run_dir::ensure_config_write_surface`])。
|
||||
//!
|
||||
//! `versions/` `libraries/` `assets/` **一个字节都不写**:登记只填 `version_id`,内容靠 D20 的
|
||||
//! 「恒共享层」被启动/下载链路复用([`crate::game::isolation::SHARED_LAYERS`])。
|
||||
@@ -13,11 +17,12 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::super::install::instance::{read_profiles, register_with, InstanceSettings, Isolation};
|
||||
use super::super::install::pack_dir;
|
||||
use super::super::layout::GameLayout;
|
||||
use super::super::profiles::ProfileList;
|
||||
use super::candidate::{AdoptCandidate, AdoptTarget};
|
||||
use super::error::AdoptError;
|
||||
use super::inspect::inspect;
|
||||
use super::run_dir;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
/// 一次登记请求(CLI / presenter 的输入形状)。
|
||||
@@ -103,6 +108,10 @@ pub fn plan(
|
||||
launcher: &LauncherLayout,
|
||||
request: &AdoptRequest,
|
||||
) -> Result<AdoptPlan, AdoptError> {
|
||||
// 配置写面必须在 `<.ULL>` 内、且不落进游戏根(R2):dry-run 也要报,别让预检说「可以」。
|
||||
// 放在 `inspect` 之前是有意的——检查候选的读盘也会穿过 `config/`(`profiles.json`),
|
||||
// 写面不可信时先停手,连读都不必走到下发。
|
||||
run_dir::ensure_config_write_surface(game, launcher)?;
|
||||
// `inspect` 只在候选**可登记**时返回 `Ok`,不合格一律是 `NotAdoptable`(问题清单已带出)。
|
||||
let candidate = inspect(game, launcher, &request.version_id)?;
|
||||
let name = resolve_name(request.name.as_deref(), &candidate.version_id);
|
||||
@@ -133,13 +142,15 @@ pub fn plan(
|
||||
));
|
||||
}
|
||||
|
||||
let run_dir = run_dir_of(
|
||||
let (run_dir, bump_notes) = run_dir_of(
|
||||
game,
|
||||
launcher,
|
||||
&candidate.version_id,
|
||||
&name,
|
||||
&request.target,
|
||||
&profiles,
|
||||
)?;
|
||||
notes.extend(bump_notes);
|
||||
notes.extend(target_notes(&request.target, &run_dir));
|
||||
|
||||
Ok(AdoptPlan {
|
||||
@@ -163,14 +174,17 @@ pub fn adopt(
|
||||
request: &AdoptRequest,
|
||||
) -> Result<AdoptOutcome, AdoptError> {
|
||||
let plan = plan(game, launcher, request)?;
|
||||
// `plan` 已核过一次;写盘前再核一次(plan 与写入之间可能有别人动过树),失败就一个字节都不写。
|
||||
run_dir::ensure_config_write_surface(game, launcher)?;
|
||||
let mut dir_created = false;
|
||||
// 目录先建:建不出来就一个配置都不写(失败的残留是一个空目录,可复用)。
|
||||
if plan.target == AdoptTarget::InstanceDir && !plan.run_dir.is_dir() {
|
||||
run_dir::ensure_inside_data(launcher, &plan.run_dir)?;
|
||||
std::fs::create_dir_all(&plan.run_dir)
|
||||
.map_err(|error| AdoptError::Game(crate::game::GameError::io(&plan.run_dir, error)))?;
|
||||
dir_created = true;
|
||||
// 建完再核一次:`create_dir_all` 会沿已存在的目录联接走,越过边界就不算成功。
|
||||
ensure_inside_data(launcher, &plan.run_dir)?;
|
||||
run_dir::ensure_inside_data(launcher, &plan.run_dir)?;
|
||||
}
|
||||
let settings = InstanceSettings {
|
||||
isolation: Some(plan.isolation),
|
||||
@@ -202,38 +216,26 @@ pub fn adopt(
|
||||
}
|
||||
|
||||
/// 运行目录的派生(缺省落点复用整合包导入的 `<data>/instances/<实例目录名>`)。
|
||||
///
|
||||
/// 缺省档**必须**查「已有实例占着哪个真实目录」(R1):slug 与大小写不敏感都会让两个不同展示名
|
||||
/// 落到同一个物理目录上;撞上就顺延后缀(见 [`run_dir::derive_instance_dir`]),而不是让两个
|
||||
/// 实例悄悄共用一个运行目录。返回(目录,顺延提示)。
|
||||
fn run_dir_of(
|
||||
game: &GameLayout,
|
||||
launcher: &LauncherLayout,
|
||||
version_id: &str,
|
||||
name: &str,
|
||||
target: &AdoptTarget,
|
||||
) -> Result<PathBuf, AdoptError> {
|
||||
profiles: &ProfileList,
|
||||
) -> Result<(PathBuf, Vec<String>), AdoptError> {
|
||||
match target {
|
||||
AdoptTarget::InstanceDir => {
|
||||
let dir = pack_dir::instance_dir(launcher, name);
|
||||
if directory_occupied(&dir) {
|
||||
return Err(AdoptError::TargetNotEmpty { path: dir });
|
||||
}
|
||||
// 数据区里**已有**的祖先不许是跑出数据根的链接/联接(junction 不需要管理员权限就能建)。
|
||||
ensure_inside_data(launcher, &dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
AdoptTarget::Shared => Ok(game.root().to_path_buf()),
|
||||
AdoptTarget::VersionDir => Ok(game.version_dir(version_id)),
|
||||
AdoptTarget::Custom(dir) => {
|
||||
if !dir.is_absolute() {
|
||||
return Err(AdoptError::TargetInvalid {
|
||||
detail: format!("--game-dir 需要绝对路径,收到 `{}`", dir.display()),
|
||||
});
|
||||
}
|
||||
if dir.as_os_str().is_empty() {
|
||||
return Err(AdoptError::TargetInvalid {
|
||||
detail: "运行目录为空".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(dir.clone())
|
||||
let claims = run_dir::claims(launcher, profiles)?;
|
||||
run_dir::derive_instance_dir(launcher, name, &claims)
|
||||
}
|
||||
AdoptTarget::Shared => Ok((game.root().to_path_buf(), Vec::new())),
|
||||
AdoptTarget::VersionDir => Ok((game.version_dir(version_id), Vec::new())),
|
||||
AdoptTarget::Custom(dir) => Ok((run_dir::resolve_custom_dir(dir)?, Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,53 +274,3 @@ fn resolve_name(name: Option<&str>, version_id: &str) -> String {
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| version_id.to_owned())
|
||||
}
|
||||
|
||||
/// 缺省运行目录必须**真的**落在启动器数据根之下(挡住「数据区里预先放了指向外面的目录联接」)。
|
||||
///
|
||||
/// 边界是 `<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())
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! 运行目录与**写盘边界**:缺省目录派生(含碰撞消歧)+ 数据区/配置写面的真实路径判据。
|
||||
//!
|
||||
//! # 两条铁律(都来自复审 r1 的实测缺陷)
|
||||
//!
|
||||
//! 1. **两个实例不许悄悄共用同一个运行目录**(R1):展示名不重复不代表派生目录不重复
|
||||
//! (`Better MC` 与 `Better-MC` 经 slug 同名;Windows 上 `Foo` 与 `foo` 是同一个目录)。
|
||||
//! 所以「目录是空的」**不足以**判可复用——还要看**已有实例已经占着哪个真实目录**;
|
||||
//! 撞上了就顺延到 `<base>-2`、`<base>-3`…(原因写进 notes),而不是让两个实例共用一个。
|
||||
//! 2. **只写 ULL 自己的配置**(R2):`config/`、`config/instances/`、`profiles.json` 的实际落点
|
||||
//! 必须在 `<.ULL>` 解析后的位置之内,且不得落进游戏根——否则 `config/instances` 被一个目录联接
|
||||
//! 指到 `.minecraft/config` 时,登记会替别人的启动器造 UUID 配置文件。
|
||||
//!
|
||||
//! # 判据的基准(为什么不拿 `<data>/instances` 或 `<.ULL>/config` 当基准)
|
||||
//!
|
||||
//! 被保护的**边界自己是链接**时,拿它当基准等于把越界当正常。所以基准一律是**解析后的父级**:
|
||||
//! 数据写面看 `<data>`,配置写面看 `<.ULL>`、并额外排除游戏根。用户把整棵树迁到别的盘
|
||||
//! (`<.ULL>` 或 `<data>` 本身是联接/符号链接)仍然允许——判据是落点,不是路径长得像不像。
|
||||
//!
|
||||
//! # 已知边界
|
||||
//!
|
||||
//! 判据是「检查那一刻」的快照:检查与 `create_dir_all`/写盘之间被人换掉组件(TOCTOU)看不到。
|
||||
//! 本命令不是并发/对抗入口,不做跨进程锁。
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::super::install::instance::read_instance_config;
|
||||
use super::super::install::pack_dir;
|
||||
use super::super::layout::GameLayout;
|
||||
use super::super::profiles::ProfileList;
|
||||
use super::error::AdoptError;
|
||||
use crate::platform::layout::LauncherLayout;
|
||||
|
||||
/// 派生目录撞车时最多顺延多少个后缀(`-2` … `-33`)。撞满即报错,不无限找。
|
||||
const MAX_SUFFIX: u32 = 32;
|
||||
|
||||
/// 一个「已有实例已经占着的运行目录」。
|
||||
///
|
||||
/// 只收 `gameDir`(现役设置文件里的,或 M27 前档案的遗留 `game_dir`):`shared` / `isolated`
|
||||
/// 档的运行目录是**派生**的(游戏根 / `versions/<id>`),与缺省落点 `<data>/instances/<名>`
|
||||
/// 不可能相等,收进来只会制造噪音。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct RunDirClaim {
|
||||
/// 占着这个目录的实例 id。
|
||||
pub(super) instance_id: String,
|
||||
/// 实例展示名(写进提示给人看)。
|
||||
pub(super) name: String,
|
||||
/// 声明/绑定的运行目录(原样,未真实化)。
|
||||
pub(super) dir: PathBuf,
|
||||
/// 比较用的键(真实化路径 + 大小写归一,见 [`dir_key`])。
|
||||
pub(super) key: String,
|
||||
}
|
||||
|
||||
/// 收集现有实例占着的运行目录(读 `config/instances/<id>.json`;读不了就是错误,不猜)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 实例设置文件读不出来 / JSON 坏:`AdoptError::Game`。
|
||||
pub(super) fn claims(
|
||||
launcher: &LauncherLayout,
|
||||
profiles: &ProfileList,
|
||||
) -> Result<Vec<RunDirClaim>, AdoptError> {
|
||||
let mut out = Vec::new();
|
||||
for profile in &profiles.profiles {
|
||||
let config = read_instance_config(launcher, profile.id.as_str())?;
|
||||
let dir = config
|
||||
.and_then(|config| config.game_dir)
|
||||
.or_else(|| profile.game_dir.clone());
|
||||
let Some(dir) = dir.filter(|dir| !dir.as_os_str().is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
out.push(RunDirClaim {
|
||||
instance_id: profile.id.as_str().to_owned(),
|
||||
name: profile.name.clone(),
|
||||
key: dir_key(&dir),
|
||||
dir,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 缺省运行目录:`<data>/instances/<实例目录名>`,撞车/占用时顺延后缀。
|
||||
///
|
||||
/// 判据顺序(三条都要过):
|
||||
/// 1. 路径上的已有祖先解析后必须仍在数据根内([`ensure_inside_data`]);
|
||||
/// 2. **不能**是已有实例占着的目录(按真实路径比较,[`dir_key`])——撞上就顺延;
|
||||
/// 3. 目录必须不存在或为空(非空且无人认领 = 用户数据,拒绝而不是换目录)。
|
||||
///
|
||||
/// 返回派生目录 + 人读提示(顺延过就有一条,没顺延就是空)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 数据区越界(`TargetInvalid`)、非空目录(`TargetNotEmpty`)、后缀撞满(`NoFreeRunDir`)、
|
||||
/// 读实例设置失败(`Game`)。
|
||||
pub(super) fn derive_instance_dir(
|
||||
launcher: &LauncherLayout,
|
||||
name: &str,
|
||||
claims: &[RunDirClaim],
|
||||
) -> Result<(PathBuf, Vec<String>), AdoptError> {
|
||||
let base = pack_dir::instance_dir(launcher, name);
|
||||
let mut bumped: Vec<RunDirClaim> = Vec::new();
|
||||
let mut attempt = base.clone();
|
||||
let mut suffix = 1;
|
||||
loop {
|
||||
ensure_inside_data(launcher, &attempt)?;
|
||||
let key = dir_key(&attempt);
|
||||
match claims.iter().find(|claim| claim.key == key) {
|
||||
Some(claim) => {
|
||||
bumped.push(claim.clone());
|
||||
suffix += 1;
|
||||
if suffix > MAX_SUFFIX {
|
||||
return Err(AdoptError::NoFreeRunDir { base });
|
||||
}
|
||||
attempt = with_suffix(&base, suffix);
|
||||
}
|
||||
None if directory_occupied(&attempt) => {
|
||||
// 非空且没有实例认领 ⇒ 用户自己的数据:不覆盖,也不偷偷换个目录把它晾在一边。
|
||||
return Err(AdoptError::TargetNotEmpty { path: attempt });
|
||||
}
|
||||
None => {
|
||||
let mut notes = Vec::new();
|
||||
if let Some(claim) = bumped.first() {
|
||||
notes.push(format!(
|
||||
"派生目录 {} 与已有实例 `{}`(id {})的运行目录 {} 是同一个物理目录,\
|
||||
已改用 {} —— 两个实例不会共用同一个运行目录",
|
||||
base.display(),
|
||||
claim.name,
|
||||
claim.instance_id,
|
||||
claim.dir.display(),
|
||||
attempt.display()
|
||||
));
|
||||
}
|
||||
return Ok((attempt, notes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `<base>-2` / `<base>-3` …(只动最后一段)。
|
||||
fn with_suffix(base: &Path, suffix: u32) -> PathBuf {
|
||||
let name = base
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
base.with_file_name(format!("{name}-{suffix}"))
|
||||
}
|
||||
|
||||
/// 缺省运行目录必须**真的**落在启动器数据根之下。
|
||||
///
|
||||
/// 边界是 `<data>`(`<root>/.ULL/data`)而不是 `<data>/instances`:后者本身可能就是链接,
|
||||
/// 拿它当基准等于把「越界」当成「正常」。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 解析后跑出数据根(`TargetInvalid`)。
|
||||
pub(super) fn ensure_inside_data(launcher: &LauncherLayout, dir: &Path) -> Result<(), AdoptError> {
|
||||
let Some(data_real) = existing_real(&launcher.data()) else {
|
||||
// 连数据根都还不存在 ⇒ 不可能已经有指向外面的链接。
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(dir_real) = existing_real(dir) {
|
||||
if !dir_real.starts_with(&data_real) {
|
||||
return Err(AdoptError::TargetInvalid {
|
||||
detail: format!(
|
||||
"运行目录 {} 解析到 {},落在数据根 {} 之外:数据区里有指向外部的符号链接/目录联接,拒绝沿它写盘",
|
||||
dir.display(),
|
||||
dir_real.display(),
|
||||
data_real.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 配置写面(`config/`、`config/instances/`、`profiles.json`)必须在 `<.ULL>` 之内,且不得落进游戏根。
|
||||
///
|
||||
/// 为什么需要:`write_atomic` 会 `create_dir_all(parent)` 再 `rename`,沿已存在的目录联接走是
|
||||
/// 基线的既定行为;但 **adopt 承诺「只写 ULL 自己的配置」**,所以这一层必须自己核真实落点。
|
||||
/// 拒绝时**一个字节都不写**(在 `plan` 阶段就返回结构化错误)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 落点跑出 `<.ULL>` 或落进游戏根:`ConfigSurfaceUnsafe`。
|
||||
pub(super) fn ensure_config_write_surface(
|
||||
game: &GameLayout,
|
||||
launcher: &LauncherLayout,
|
||||
) -> Result<(), AdoptError> {
|
||||
let Some(home_real) = existing_real(launcher.home()) else {
|
||||
// `<.ULL>` 都还不存在 ⇒ 还不可能有链接可言。
|
||||
return Ok(());
|
||||
};
|
||||
let game_real = existing_real(game.root());
|
||||
// 判据的**真实归属**解释(不是「特殊布局豁免」):
|
||||
// - 主判据:真实落点必须在 `<.ULL>` 之内。落在外面一律拒绝,并按真实目标分类给原因;
|
||||
// - 附加判据:`.ULL` 与 `.minecraft` **并列**时,落点还必须在游戏根之外——挡住
|
||||
// `config/instances -> <game>/config` 这种「没跑出 .ULL 的亲戚、但确实落进游戏根」的绕法;
|
||||
// - 若 `.ULL` 自己就在游戏根里(用户把 `core.game_home` 指到启动器根这种布局),
|
||||
// 「`.ULL` 内的落点也在游戏根内」是布局事实,附加判据在该布局下**没有判别力**
|
||||
// (主判据已经把它包含进来了),所以只在 `.ULL` 不在游戏根内时启用;这不是给链接开后门:
|
||||
// 任何跑出 `.ULL` 的落点都由主判据拦下。
|
||||
let home_inside_game = game_real
|
||||
.as_ref()
|
||||
.is_some_and(|game_real| home_real.starts_with(game_real));
|
||||
let targets = [
|
||||
launcher.config(),
|
||||
launcher.config().join("instances"),
|
||||
launcher.profiles_config(),
|
||||
];
|
||||
for target in targets {
|
||||
let Some(real) = existing_real(&target) else {
|
||||
continue;
|
||||
};
|
||||
let in_game = game_real
|
||||
.as_ref()
|
||||
.is_some_and(|game_real| real.starts_with(game_real));
|
||||
if !real.starts_with(&home_real) {
|
||||
let detail = if in_game {
|
||||
format!(
|
||||
"解析到 {},经链接落进了游戏根 {}(本启动器只写自己的配置,绝不在别人的 .minecraft 里造文件)",
|
||||
real.display(),
|
||||
game_real.as_ref().map_or_else(|| "<游戏根>".to_owned(), |p| p.display().to_string())
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"解析到 {},落在启动器目录 {} 之外(有指向外部的符号链接/目录联接)",
|
||||
real.display(),
|
||||
home_real.display()
|
||||
)
|
||||
};
|
||||
return Err(AdoptError::ConfigSurfaceUnsafe {
|
||||
path: target,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
if home_inside_game || !in_game {
|
||||
continue;
|
||||
}
|
||||
return Err(AdoptError::ConfigSurfaceUnsafe {
|
||||
path: target,
|
||||
detail: format!(
|
||||
"解析到 {},落进了游戏根 {}(本启动器只写自己的配置,绝不在别人的 .minecraft 里造文件)",
|
||||
real.display(),
|
||||
game_real
|
||||
.as_ref()
|
||||
.map_or_else(|| "<游戏根>".to_owned(), |p| p.display().to_string())
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `custom` 运行目录的用法判据(绝对路径 / 非空)。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 相对路径或空路径:`TargetInvalid`。
|
||||
pub(super) fn resolve_custom_dir(dir: &Path) -> Result<PathBuf, AdoptError> {
|
||||
if dir.as_os_str().is_empty() {
|
||||
return Err(AdoptError::TargetInvalid {
|
||||
detail: "运行目录为空".to_owned(),
|
||||
});
|
||||
}
|
||||
if !dir.is_absolute() {
|
||||
return Err(AdoptError::TargetInvalid {
|
||||
detail: format!("--game-dir 需要绝对路径,收到 `{}`", dir.display()),
|
||||
});
|
||||
}
|
||||
Ok(dir.to_path_buf())
|
||||
}
|
||||
|
||||
/// 目录是否「有人在用」(存在且非空)。空目录是上次失败的残留,可复用。
|
||||
pub(super) fn directory_occupied(path: &Path) -> bool {
|
||||
std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_some())
|
||||
}
|
||||
|
||||
/// **比较用**的真实路径键:解析链接 + 拼回尚不存在的尾段 + 统一分隔符 + 大小写归一。
|
||||
///
|
||||
/// 大小写归一只在大小写不敏感的平台做(Windows / 默认 macOS):`Foo` 与 `foo` 是同一个目录,
|
||||
/// 不归一会让「两个实例共用目录」这一条在 Windows 上漏掉(复审 r1 的实测之一)。
|
||||
///
|
||||
/// **策略是保守的**:macOS 也可能是大小写敏感的 APFS 卷,那时 `Foo` 与 `foo` 本来是**不同**目录,
|
||||
/// 归一后会判成碰撞 ⇒ 顺延 `-2`。代价是目录名可能多一个后缀,换来的是「绝不共用」这条不变量
|
||||
/// 在任何卷上都成立(判错的方向只能是「多一次顺延」,不能是「共用一个目录」)。
|
||||
///
|
||||
/// **目标可能还不存在**:这里不要求路径存在([`existing_real`] 会拼回尚不存在的尾段),
|
||||
/// 因为「实例已经认领了、但目录还没被创建」(`custom` 档就是这种)同样必须参与比较。
|
||||
fn dir_key(path: &Path) -> String {
|
||||
match existing_real(path) {
|
||||
Some(real) => normalize_key(&real),
|
||||
None => normalize_key(path),
|
||||
}
|
||||
}
|
||||
|
||||
/// 路径 → 比较键(`/` 分隔 + 大小写归一)。
|
||||
fn normalize_key(path: &Path) -> String {
|
||||
let text = path.to_string_lossy().replace('\\', "/");
|
||||
if cfg!(any(windows, target_os = "macos")) {
|
||||
text.to_lowercase()
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
/// **最近**的已存在祖先/自身解析出的真实路径 + 尚不存在的尾段(`None` = 整条链都不存在)。
|
||||
///
|
||||
/// 目标本身不存在时**保留**它的尾段:否则 `instances/Foo` 与 `instances/foo` 都会退化成
|
||||
/// `instances` 的真实路径,两个不同目录被当成同一个(或反过来永远判不相等)。
|
||||
pub(super) fn existing_real(path: &Path) -> Option<PathBuf> {
|
||||
let mut probe = path.to_path_buf();
|
||||
let mut tail: Vec<OsString> = Vec::new();
|
||||
loop {
|
||||
if std::fs::symlink_metadata(&probe).is_ok() {
|
||||
let real = probe.canonicalize().ok()?;
|
||||
let mut full = real;
|
||||
for part in tail.iter().rev() {
|
||||
full.push(part);
|
||||
}
|
||||
return Some(full);
|
||||
}
|
||||
let name = probe.file_name().map(OsString::from)?;
|
||||
tail.push(name);
|
||||
if !probe.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user