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:
1 parent
0577ee87a3
commit
9f5c001387
25 files changed
+3460
-73
No files matched your search
@@ -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(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user