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
@@ -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())
|
||||
}
|
||||
Reference in new issue
Block a user