feat(game): 从其他启动器只读登记已有版本(ull instances scan|adopt)+ 登记可一并定隔离档(M221)

盘上已经铺好的版本(多半是 PCL/HMCL 装的)今天在 ULL 里看不见,用户只能重装一遍。这个
commit 把它们登记成实例,**只读外部、只写自己的配置**:不搬文件、不补清单、不改任何外部
文件的 mtime(versions/ libraries/ assets/ 一个字节都不写)。

core(`game::adopt`,新模块):
- `scan` / `inspect`:只读扫描 + 逐项检查,坏 JSON(带行列号)/ 缺 id / id 与目录名不一致 /
  缺父 / 成环 / 读不出来 / 越界 / 同名冲突一律**结构化**报错(稳定 `code`),不吞成空列表;
  `versions/` 不存在才是空结果,读不了就是错误;
- 越界判据用 canonicalize 后的前缀比较,同时覆盖**符号链接、Windows 目录联接(junction)与
  祖先链接**(junction 不需要管理员权限,是最常见的「把别人版本目录挂进来」);越界条目在
  边界检查就停手,一个字节都不去读;
- `plan`(零写盘,喂 dry-run 与后续 GUI 预检)/ `adopt`(plan + 只写自己的配置):
  **缺省运行目录落 ULL 数据区 `<data>/instances/<实例目录名>`(custom 档),绝不沿用全局
  `isolated`**——那条缺省的运行目录正是 `versions/<versionId>/`,会在他人的版本目录里长出
  mods/saves/config;`shared` / `isolated` 必须显式选,且 `target_notes()` 给出后果话术;
- 缺省运行目录已存在且非空 ⇒ 拒绝(不覆盖、不猜「合并进已有实例」);数据区里已有的祖先
  若解析到数据根之外(符号链接/联接)⇒ 拒绝,不沿它写盘;
- 登记与隔离档一次写清:`install/instance` 拆出 `register` 子模块,新增
  `register_with(InstanceSettings)`;写盘顺序改成「先实例设置、后 profiles」(中断只留孤立
  设置文件,不会留下按全局档启动的实例),profiles 写失败会撤掉刚写的设置。`register`
  签名与行为不变(`register` = `register_with(inherit)`;同版本同名的幂等复用照旧)。

CLI:
- `ull instances scan`(只读列出候选与原因)、`ull instances adopt <version-id> [--name N]
  [--isolation shared|isolated|custom] [--game-dir DIR] [--dry-run]`;`--game-dir` 单独给即
  custom,相对路径在解析期就报用法错误;
- 人读/`--json`/help 三条出口齐备(`error.detail.problems` 逐条可分流),已有命令未改。

测试(真磁盘夹具,`cargo test -p unlucky-core --test adopt` 22 passed /
`cargo test -p unlucky-cli --test adopt_test` 10 passed):
- 一份「别的启动器铺好的」`.minecraft`(versions/libraries/assets/mods/saves/config +
  launcher_profiles.json),扫描与登记前后做「路径集合 + 字节数 + 内容哈希 + mtime」全等断言;
- 正常 / 缺清单 / 坏 JSON / 缺父 / 成环 / id 不匹配 / 非法 id / 符号链接越界 / 根内符号链接
  放行 / **junction 越界** / 数据区 junction 写盘边界 / 缺省非 isolated / shared 与 isolated
  的话术 / 重名 / 重复 / 非空目录 / 空目录复用 / dry-run 零写盘;
- CLI 侧验 `launch --dry-run` 的 `--gameDir` 指向选定运行目录(argv 已脱敏,比尾段;
  `data.game_dir` 全等);
- 变异核对(只改实现、跑完即回滚):缺省档改 `VersionDir` ⇒ 红;边界判据摘掉 ⇒ 符号链接与
  junction 两条红;写盘边界摘掉 ⇒ 数据区 junction 那条红;缺父当链终点 ⇒ 两条红。

界限:没有启动真实 Minecraft;GUI(presenter/app-iced)接线另派;D20 单游戏根模型未改
(不扫第二个 `.minecraft`)。
This commit is contained in:
AskaEth committed 2026-11-09 22:21:37 +08:00
1 parent 0577ee87a3
commit 9f5c001387
25 files changed
+3460 -73

No files matched your search

+353
View File
@@ -0,0 +1,353 @@
//! 只读扫描与逐项检查:`versions/` 下哪些版本能登记、不能登记的原因是什么。
//!
//! # 三条不许偷懒的判据
//!
//! 1. **坏东西不能当空列表**:`versions/` 读不了是错误([`AdoptError::Game`]),一个版本的清单坏了是
//! 一条 [`AdoptProblem`];两条都不会变成「这里什么都没有」。
//! 2. **继承链要走到头**:缺父、成环、父清单坏了、父的清单 id 与目录名不一致,逐条报清楚是哪个文件。
//! 3. **不跟随越界符号链接**:`versions/<id>` 或清单是符号链接时,解析出的真实路径必须仍在游戏根内
//! (与导出侧「符号链接不跟随」同一政策)。
//!
//! 全程**只读**:不建目录、不写文件、不改 mtime。
use std::path::{Path, PathBuf};
use super::super::error::GameError;
use super::super::inherit;
use super::super::install::instance::read_profiles;
use super::super::layout::GameLayout;
use super::super::manifest::VersionJson;
use super::super::profiles::ProfileList;
use super::candidate::{AdoptCandidate, AdoptProblem, RegisteredInstance};
use super::error::AdoptError;
use super::paths::{ensure_inside, root_of, validate_version_id};
use crate::platform::layout::LauncherLayout;
/// 一次扫描的结果(`versions/` 里的全部条目,逐项带问题)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdoptScan {
/// 被扫的目录(`<游戏根>/versions`)。
pub versions_dir: PathBuf,
/// 候选版本(按版本 id 字典序)。
pub candidates: Vec<AdoptCandidate>,
}
/// 扫当前游戏根的 `versions/`,逐个检查。
///
/// `versions/` **不存在** ⇒ 空结果(新装的数据根,没有可登记的东西,不是错误);
/// `versions/` 存在但读不了 ⇒ [`AdoptError::Game`](绝不降级成空列表)。
///
/// # Errors
///
/// 读目录 / 读 `profiles.json` 失败;条目元数据读不出来(权限)时也直接失败——那属于
/// 「环境坏了」而不是「这个版本不能登记」。
pub fn scan(game: &GameLayout, launcher: &LauncherLayout) -> Result<AdoptScan, AdoptError> {
let profiles = read_profiles(launcher)?;
let versions_dir = game.versions_dir();
let root = root_of(game);
let entries = match std::fs::read_dir(&versions_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(AdoptScan {
versions_dir,
candidates: Vec::new(),
})
}
Err(error) => return Err(GameError::io(&versions_dir, error).into()),
};
let mut candidates: Vec<AdoptCandidate> = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| GameError::io(&versions_dir, error))?;
let path = entry.path();
let Some(version_id) = path.file_name().and_then(|name| name.to_str()) else {
// 非 UTF-8 目录名不能当版本 id(进不了 profiles.json),如实记一条问题而不是丢掉。
candidates.push(unreadable_id_candidate(&path, &versions_dir));
continue;
};
match std::fs::metadata(&path) {
Ok(meta) if meta.is_dir() => {}
Ok(_) => continue, // `versions/` 里的普通文件不是版本目录
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
// 断链符号链接:元数据跟不过去,但链接本身在。
if std::fs::symlink_metadata(&path).is_ok() {
candidates.push(broken_link_candidate(version_id, &path));
}
continue;
}
Err(error) => return Err(GameError::io(&path, error).into()),
}
candidates.push(check(game, &root, version_id, &profiles));
}
candidates.sort_by(|a, b| a.version_id.cmp(&b.version_id));
Ok(AdoptScan {
versions_dir,
candidates,
})
}
/// 检查单个版本。
///
/// 与 [`scan`] 用同一套判据,但**先**校验 id 能不能当路径用(非法 id 连存在性都不该去探,
/// 否则 `versions/../../外面的/1.20.1` 这种形状会先撞上「目录真的存在」再报出别的结论)。
/// 候选不合格一律 [`AdoptError::NotAdoptable`] 且把问题清单原样带出,只有环境级失败才 `Err`。
///
/// # Errors
///
/// - [`AdoptError::NotAdoptable`]:id 非法,或候选有问题(坏清单 / 缺父 / 环 / 越界…);
/// - [`AdoptError::UnknownVersion`]:id 合法但 `versions/<id>/` 不存在;
/// - [`AdoptError::Game`]:读 `profiles.json` 失败。
pub fn inspect(
game: &GameLayout,
launcher: &LauncherLayout,
version_id: &str,
) -> Result<AdoptCandidate, AdoptError> {
if let Err(problem) = validate_version_id(version_id) {
return Err(AdoptError::NotAdoptable {
version_id: version_id.to_owned(),
problems: vec![problem],
});
}
if !game.version_dir(version_id).is_dir() {
return Err(AdoptError::UnknownVersion {
version_id: version_id.to_owned(),
version_dir: game.version_dir(version_id),
});
}
let profiles = read_profiles(launcher)?;
let candidate = check(game, &root_of(game), version_id, &profiles);
if candidate.adoptable() {
Ok(candidate)
} else {
Err(AdoptError::NotAdoptable {
version_id: candidate.version_id.clone(),
problems: candidate.problems.clone(),
})
}
}
/// 逐项检查一个版本目录。
fn check(
game: &GameLayout,
root: &Path,
version_id: &str,
profiles: &ProfileList,
) -> AdoptCandidate {
let version_dir = game.version_dir(version_id);
let manifest_path = game.version_json(version_id);
let registered = registered_for(profiles, version_id);
let mut candidate = AdoptCandidate {
version_id: version_id.to_owned(),
version_dir: version_dir.clone(),
manifest_path: manifest_path.clone(),
jar_present: false,
manifest_id: None,
inherits: Vec::new(),
registered,
problems: Vec::new(),
};
// ① id 能不能当路径用:不能的话后面的 join 全不可信,直接结束。
if let Err(problem) = validate_version_id(version_id) {
candidate.problems.push(problem);
return candidate;
}
// ② 版本目录解析到根外(符号链接 / 目录联接 / 祖先链接)⇒ 立刻停手:
// **一个字节都不去读**那个目录(读它就等于读别人的启动器数据)。
if let Some(problem) = ensure_inside(&version_dir, root) {
candidate.problems.push(problem);
return candidate;
}
// ③ 清单必须在(`installed_versions` 的判据同源)。
if !manifest_path.is_file() {
candidate.problems.push(AdoptProblem::ManifestMissing {
version_id: version_id.to_owned(),
path: manifest_path,
});
return candidate;
}
if let Some(problem) = ensure_inside(&manifest_path, root) {
candidate.problems.push(problem);
return candidate;
}
candidate.jar_present = game.version_jar(version_id).is_file();
// ④ 清单读得出来、id 对得上。
let manifest = match read_manifest(&manifest_path) {
Ok(manifest) => manifest,
Err(problem) => {
candidate.problems.push(problem);
return candidate;
}
};
let manifest_id = manifest.id.clone();
candidate.manifest_id = manifest_id.clone();
match manifest_id.as_deref() {
None => {
candidate.problems.push(AdoptProblem::ManifestMissingId {
path: manifest_path.clone(),
});
return candidate;
}
Some(id) if id != version_id => {
candidate.problems.push(AdoptProblem::ManifestIdMismatch {
path: manifest_path.clone(),
manifest_id: id.to_owned(),
directory_id: version_id.to_owned(),
});
}
Some(_) => {}
}
// ⑤ 继承链走到底(缺父 / 环 / 父清单坏 / 父 id 不匹配都在这条路上报)。
match walk_chain(game, root, &manifest_path, manifest) {
Ok(chain) => {
candidate.inherits = chain;
// 走查通过后再让 `resolve` 做一次权威判定:两处判据不许漂。
if let Some(problem) = resolve_verdict(game, &candidate.manifest_path) {
candidate.problems.push(problem);
}
}
Err(problem) => candidate.problems.push(problem),
}
candidate
}
/// 走 `inheritsFrom` 链,返回 [自身, 父, 祖父, …];任一层不合格就返回那条问题。
fn walk_chain(
game: &GameLayout,
root: &Path,
manifest_path: &Path,
manifest: VersionJson,
) -> Result<Vec<String>, AdoptProblem> {
let Some(self_id) = manifest.id.clone() else {
return Err(AdoptProblem::ManifestMissingId {
path: manifest_path.to_path_buf(),
});
};
let mut chain = vec![self_id];
let mut current = manifest;
while let Some(parent) = current.inherits_from.clone() {
if chain.iter().any(|id| id == &parent) {
let mut loop_chain = chain.clone();
loop_chain.push(parent);
return Err(AdoptProblem::CircularInheritance { chain: loop_chain });
}
validate_version_id(&parent)?;
let required_by = chain.last().cloned().unwrap_or_default();
let parent_dir = game.version_dir(&parent);
let parent_path = game.version_json(&parent);
// 父版本同样先过边界(越界就不读),再判存在。
if let Some(problem) = ensure_inside(&parent_dir, root) {
return Err(problem);
}
if !parent_path.is_file() {
return Err(AdoptProblem::MissingParent {
parent,
required_by,
});
}
if let Some(problem) = ensure_inside(&parent_path, root) {
return Err(problem);
}
let parent_manifest = read_manifest(&parent_path)?;
match parent_manifest.id.as_deref() {
None => return Err(AdoptProblem::ManifestMissingId { path: parent_path }),
Some(id) if id != parent => {
return Err(AdoptProblem::ManifestIdMismatch {
path: parent_path,
manifest_id: id.to_owned(),
directory_id: parent,
})
}
Some(_) => {}
}
chain.push(parent);
current = parent_manifest;
}
Ok(chain)
}
/// 用 `core::game::resolve` 判一次继承链(权威结论;失败就如实报,不猜)。
fn resolve_verdict(game: &GameLayout, manifest_path: &Path) -> Option<AdoptProblem> {
let manifest = read_manifest(manifest_path).ok()?;
let source = |id: &str| -> Option<VersionJson> {
let bytes = std::fs::read(game.version_json(id)).ok()?;
VersionJson::from_json_slice(&bytes).ok()
};
match inherit::resolve(&source, &manifest) {
Ok(_) => None,
Err(error) => Some(AdoptProblem::InheritanceInvalid {
detail: error.to_string(),
}),
}
}
/// 读一份清单(错误带路径与 serde 的行列号)。
fn read_manifest(path: &Path) -> Result<VersionJson, AdoptProblem> {
let bytes = std::fs::read(path).map_err(|error| AdoptProblem::ManifestUnreadable {
path: path.to_path_buf(),
message: error.to_string(),
})?;
VersionJson::from_json_slice(&bytes).map_err(|error| match error {
GameError::Json { source, .. } => AdoptProblem::ManifestInvalidJson {
path: path.to_path_buf(),
message: source.to_string(),
},
other => AdoptProblem::ManifestInvalidJson {
path: path.to_path_buf(),
message: other.to_string(),
},
})
}
/// 绑定这个版本 id 的全部实例档案。
fn registered_for(profiles: &ProfileList, version_id: &str) -> Vec<RegisteredInstance> {
profiles
.profiles
.iter()
.filter(|profile| profile.version_id == version_id)
.map(|profile| RegisteredInstance {
id: profile.id.as_str().to_owned(),
name: profile.name.clone(),
})
.collect()
}
/// 非 UTF-8 目录名:当成一条不可登记的候选(丢掉它就等于「静默少一行」)。
fn unreadable_id_candidate(path: &Path, versions_dir: &Path) -> AdoptCandidate {
let shown = path
.strip_prefix(versions_dir)
.unwrap_or(path)
.to_string_lossy()
.into_owned();
AdoptCandidate {
version_id: shown,
version_dir: path.to_path_buf(),
manifest_path: path.to_path_buf(),
jar_present: false,
manifest_id: None,
inherits: Vec::new(),
registered: Vec::new(),
problems: vec![AdoptProblem::DirectoryUnreadable {
path: path.to_path_buf(),
message: "目录名不是合法 UTF-8,不能作为版本 id".to_owned(),
}],
}
}
/// 断链符号链接:`versions/` 里有这个条目,但指不到任何东西。
fn broken_link_candidate(version_id: &str, path: &Path) -> AdoptCandidate {
AdoptCandidate {
version_id: version_id.to_owned(),
version_dir: path.to_path_buf(),
manifest_path: path.to_path_buf(),
jar_present: false,
manifest_id: None,
inherits: Vec::new(),
registered: Vec::new(),
problems: vec![AdoptProblem::SymlinkUnresolvable {
path: path.to_path_buf(),
message: "符号链接的目标不存在(断链)".to_owned(),
}],
}
}