Files
Unlucky-Launcher/crates/core/src/game/adopt/plan.rs
T
AskaEth 8110cde3f5 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 重解析点仍列边界。
2026-11-09 23:13:57 +08:00

277 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 落点计算与真正的登记:`plan`(纯算 + 零写盘)与 `adopt`(plan + 只写自己的两份配置)。
//!
//! # 写盘面(全部在 ULL 数据根下,游戏根一个字节都不动)
//!
//! 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`])。
use std::path::{Path, PathBuf};
use super::super::install::instance::{read_profiles, register_with, InstanceSettings, Isolation};
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 的输入形状)。
#[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> {
// 配置写面必须在 `<.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);
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, 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 {
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)?;
// `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` 会沿已存在的目录联接走,越过边界就不算成功。
run_dir::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/<实例目录名>`)。
///
/// 缺省档**必须**查「已有实例占着哪个真实目录」(R1):slug 与大小写不敏感都会让两个不同展示名
/// 落到同一个物理目录上;撞上就顺延后缀(见 [`run_dir::derive_instance_dir`]),而不是让两个
/// 实例悄悄共用一个运行目录。返回(目录,顺延提示)。
fn run_dir_of(
game: &GameLayout,
launcher: &LauncherLayout,
version_id: &str,
name: &str,
target: &AdoptTarget,
profiles: &ProfileList,
) -> Result<(PathBuf, Vec<String>), AdoptError> {
match target {
AdoptTarget::InstanceDir => {
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())),
}
}
/// 运行目录选择的后果说明(人读;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())
}