r1 五项里 R4/R5 与 slug/大小写等直接案例已由 r2 复审独立通过;这三条是它补证出的**剩余边界**, 只做限定修复,不推翻重写: r2.1(未存在认领路径的 Win32 尾点/尾空格别名):比较键新增 `fspaths::comparison_real()`—— 已存在前缀用 `canonicalize`,**尚不存在的尾段按平台语义归一**:Windows 普通路径折叠尾 `.`/空格 (`Foo.` 与 `Foo ` 都是 `Foo`,Win32 建目录时就这么做),**verbatim 路径(`\?\…`)不折叠** (那是另一个物理目录,合法)。原先 `dir_key` 只统一分隔符与大小写,于是一个尚未创建的 `custom` 认领 `Foo.` 与缺省派生 `Foo` 键不相等却被判定「不撞」,两个实例共用一个运行目录。 r2.2(`data`/`.ULL` 整体映射到受保护游戏树时仍静默外写):新增 `fspaths::ensure_surfaces_clear_of_game_tree()`——默认写盘面(`.ULL`、`config/`、 `config/instances/`、`profiles.json`、`data/`,以及缺省档的运行目录)**解析后不得落进游戏根**; `plan`(dry-run 也报)与 `adopt`(写盘前)各核一次,重叠即 `adopt_write_surface_in_game_tree` 具名拒绝并提示把数据目录挪到游戏树之外。整体迁移到游戏树**之外**照旧允许(判据是落点不是链接)。 配置写面判据同时收窄职责:`ensure_config_write_surface` 只管「在 `.ULL` 内」, 不再带「`.ULL` 在游戏根里」的豁免特例——重叠由上面那条统一负责。 r2.3(游戏根更高祖先断链仍当空列表):`scan` 的 `NotFound` 分支改为沿祖先找**最近的已存在入口**, 它能 `lstat` 到但 `canonicalize` 不出来(断链的符号链接/目录联接、目标被搬走)⇒ `adopt_versions_unreadable`;一路到盘根都不存在才是「真正的新数据根」,空结果仍允许。 原先只查 `versions` 与游戏根两层,迁移联接在更高层断开时会误报空库。 新错误码:`adopt_write_surface_in_game_tree`(CLI hint 与 help 拒绝清单同步)。 验证(持久日志在 `target/m221-r2/`,git 忽略、不入库;上一轮 `target/m221-r1/` 证据未覆盖): - `cargo fmt --all --check` 0;`cargo check -p unlucky-core -p unlucky-cli --all-targets` 0; - `cargo test -p unlucky-core --test adopt` **43 passed / 0 failed**(另跑一次 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 同样 43 passed); - `cargo test -p unlucky-cli --test adopt_test` **13 passed / 0 failed**; - `cargo clippy -p unlucky-core -p unlucky-cli --all-targets -- -D warnings` 0; - 额外:`cargo test -p unlucky-core --lib` **778 passed**、`cargo test -p unlucky-cli` 全部目标通过; - 三次**实现-only 变异**(不折叠 Win32 尾点尾空格 / 不核游戏树重叠 / NotFound 直接当空) 全部以**断言红**收场(1 / 4 / 2 条失败,非编译错误、非环境错误),每次还原后与基线逐文件一致, 收尾再跑 core+cli 双绿。 界限:只覆盖 core+cli 的定向目标(不等于 workspace 全量);没有启动真实 Minecraft;GUI 未接; 判据是检查那一刻的快照(TOCTOU 看不到);Win32 8.3 短名与非 junction 重解析点未覆盖。
401 lines
16 KiB
Rust
401 lines
16 KiB
Rust
//! 只读扫描与逐项检查:`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::VersionsDirUnreadable`],绝不降级成空列表;其它读目录失败 ⇒ [`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 => {
|
||
// **入口还在但读不出来**不是「新数据根」:Windows 上断链的目录联接也只会给 NotFound,
|
||
// 必须自己用不跟随链接的元数据分辨(复审 r1 的实测:lstat 成功而命令报「空」)。
|
||
//
|
||
// r2 R2.3:断链可能发生在**更高的祖先**上(例如 `--data-dir` 指向迁移联接下面的 nested
|
||
// 目录,联接目标被搬走后 `versions` 与游戏根本身都 NotFound)。所以沿祖先找**最近的
|
||
// 已存在入口**:它 lstat 得到但 `canonicalize` 不出来 ⇒ 具名报错;一路到盘根都不存在
|
||
// ⇒ 才是「真正的新数据根」,空结果是诚实的。
|
||
if let Some(entry) = nearest_existing_entry(&versions_dir) {
|
||
if entry.canonicalize().is_err() {
|
||
return Err(AdoptError::VersionsDirUnreadable {
|
||
path: entry,
|
||
detail: "最近的已存在入口解析失败(断链的符号链接/目录联接,或目标被移走)"
|
||
.to_owned(),
|
||
});
|
||
}
|
||
}
|
||
|
||
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() {
|
||
let version_dir = game.version_dir(version_id);
|
||
// 条目存在但不是可读目录(断链链接/联接、或同名的是个文件)⇒ 具名报错,别混成「没装」。
|
||
if std::fs::symlink_metadata(&version_dir).is_ok() {
|
||
return Err(AdoptError::NotAdoptable {
|
||
version_id: version_id.to_owned(),
|
||
problems: vec![AdoptProblem::DirectoryUnreadable {
|
||
path: version_dir,
|
||
message: "条目存在但不是可读目录(断链 / 联接目标被移走 / 同名的是文件)"
|
||
.to_owned(),
|
||
}],
|
||
});
|
||
}
|
||
return Err(AdoptError::UnknownVersion {
|
||
version_id: version_id.to_owned(),
|
||
version_dir,
|
||
});
|
||
}
|
||
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(),
|
||
}],
|
||
}
|
||
}
|
||
|
||
/// 沿祖先找**最近的已存在入口**(自身优先;一路到盘根都没有 ⇒ `None`)。
|
||
///
|
||
/// 用途:`read_dir` 的 `NotFound` 不代表「这个数据根是空的」——断链可能发生在更高的祖先上
|
||
/// (迁移联接的目标被搬走时,`versions` 和游戏根都 NotFound,但入口还在)。判据是**存在性**
|
||
/// (`symlink_metadata`,不跟随),能不能解析由调用方另行判定。
|
||
fn nearest_existing_entry(path: &Path) -> Option<PathBuf> {
|
||
let mut probe = path.to_path_buf();
|
||
loop {
|
||
if std::fs::symlink_metadata(&probe).is_ok() {
|
||
return Some(probe);
|
||
}
|
||
if !probe.pop() {
|
||
return None;
|
||
}
|
||
}
|
||
}
|