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:
AskaEth committed 2026-11-09 23:13:57 +08:00
1 parent 9f5c001387
commit 8110cde3f5
19 files changed
+1363 -272

No files matched your search

+29 -77
View File
@@ -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())
}