复审 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 重解析点仍列边界。
277 lines
12 KiB
Rust
277 lines
12 KiB
Rust
//! 落点计算与真正的登记:`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())
|
||
}
|