复审 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 重解析点仍列边界。
348 lines
15 KiB
Rust
348 lines
15 KiB
Rust
//! `scan` / `inspect` 的判据:正常版本能不能过、坏东西是不是**逐条**说清楚。
|
||
//!
|
||
//! 每条断言下面都标了「抓得住 / 抓不住」:抓得住的是变异实现会变红的具体结论,
|
||
//! 抓不住的是只能靠人读的东西(措辞、是否还有别的静默吞掉路径)。
|
||
|
||
use unlucky_core::game::adopt::{self, AdoptCandidate};
|
||
|
||
use crate::fixture::{assert_unchanged, foreign_minecraft, snapshot, TempRoot};
|
||
|
||
/// 扫一次并按 id 取候选(`inspect` 对不合格候选返回 `Err`,扫描则逐项列出,断言更好写)。
|
||
fn candidate_of(root: &TempRoot, version_id: &str) -> AdoptCandidate {
|
||
let scan = adopt::scan(&root.game(), &root.launcher()).expect("扫描应成功");
|
||
scan.candidates
|
||
.into_iter()
|
||
.find(|candidate| candidate.version_id == version_id)
|
||
.unwrap_or_else(|| panic!("扫描里没有 {version_id}"))
|
||
}
|
||
|
||
/// 正常 / 缺清单 / 坏 JSON / 缺父 / 成环:一次扫描把每个候选的结论都说出来。
|
||
///
|
||
/// 抓得住:`scan` 把坏东西当空列表(`candidates` 少项就红)、漏掉父链(`inherits` 长度变)、
|
||
/// 缺父被当成可登记(`adoptable` 变真)、成环没被检出(`cycle-*` 会变可登记)。
|
||
/// 抓不住:问题文案的措辞,以及 `versions/` 之外的地方有没有被读写(由下面的哈希断言覆盖)。
|
||
#[test]
|
||
fn scan_reports_every_version_with_its_reason() {
|
||
let root = TempRoot::new("scan");
|
||
foreign_minecraft(&root.game_root());
|
||
let before = snapshot(root.path());
|
||
|
||
let scan = adopt::scan(&root.game(), &root.launcher()).expect("扫描应成功");
|
||
let after = snapshot(root.path());
|
||
assert_unchanged(&before, &after, "scan 之后");
|
||
|
||
let ids: Vec<&str> = scan
|
||
.candidates
|
||
.iter()
|
||
.map(|candidate| candidate.version_id.as_str())
|
||
.collect();
|
||
assert_eq!(
|
||
ids,
|
||
vec![
|
||
"1.20.1",
|
||
"1.20.1-broken",
|
||
"1.20.1-fabric-0.15.0",
|
||
"1.20.1-nomanifest",
|
||
"1.20.1-orphan",
|
||
"cycle-a",
|
||
"cycle-b",
|
||
],
|
||
"扫描必须列出每一个目录(含不可登记的),不能少一行"
|
||
);
|
||
|
||
let by_id = |id: &str| {
|
||
scan.candidates
|
||
.iter()
|
||
.find(|candidate| candidate.version_id == id)
|
||
.unwrap_or_else(|| panic!("缺候选 {id}"))
|
||
};
|
||
|
||
let vanilla = by_id("1.20.1");
|
||
assert!(
|
||
vanilla.adoptable(),
|
||
"正常版本应可登记:{:?}",
|
||
vanilla.problems
|
||
);
|
||
assert!(vanilla.jar_present);
|
||
assert_eq!(vanilla.inherits, vec!["1.20.1"]);
|
||
assert!(!vanilla.is_registered());
|
||
|
||
let fabric = by_id("1.20.1-fabric-0.15.0");
|
||
assert!(fabric.adoptable(), "{:?}", fabric.problems);
|
||
assert!(!fabric.jar_present, "fabric 版本自己没有 jar");
|
||
assert_eq!(
|
||
fabric.inherits,
|
||
vec!["1.20.1-fabric-0.15.0", "1.20.1"],
|
||
"父链必须走到底"
|
||
);
|
||
|
||
let codes = |id: &str| -> Vec<&'static str> {
|
||
by_id(id)
|
||
.problems
|
||
.iter()
|
||
.map(|problem| problem.code())
|
||
.collect()
|
||
};
|
||
assert_eq!(codes("1.20.1-broken"), vec!["manifest_invalid_json"]);
|
||
assert_eq!(codes("1.20.1-nomanifest"), vec!["manifest_missing"]);
|
||
assert_eq!(codes("1.20.1-orphan"), vec!["missing_parent"]);
|
||
assert_eq!(codes("cycle-a"), vec!["circular_inheritance"]);
|
||
assert_eq!(codes("cycle-b"), vec!["circular_inheritance"]);
|
||
for id in ["1.20.1-broken", "1.20.1-orphan", "cycle-a"] {
|
||
assert!(!by_id(id).adoptable(), "{id} 不该被判成可登记");
|
||
}
|
||
}
|
||
|
||
/// 缺父要说清**缺哪个**、成环要说清**环长什么样**(不是一句「继承有问题」)。
|
||
///
|
||
/// 抓得住:`MissingParent` 丢了 `parent` 字段、环链只剩最后一个 id(两个都会被下面的 contains 抓到)。
|
||
/// 抓不住:错误码是否被 CLI 原样透出(那条在 cli/tests/adopt_test 里)。
|
||
#[test]
|
||
fn missing_parent_and_cycle_name_what_is_wrong() {
|
||
let root = TempRoot::new("chain");
|
||
foreign_minecraft(&root.game_root());
|
||
let scan = adopt::scan(&root.game(), &root.launcher()).expect("扫描应成功");
|
||
let problems = |id: &str| {
|
||
scan.candidates
|
||
.iter()
|
||
.find(|candidate| candidate.version_id == id)
|
||
.expect("有候选")
|
||
.problems
|
||
.clone()
|
||
};
|
||
|
||
let orphan = problems("1.20.1-orphan");
|
||
let message = orphan.first().expect("一条问题").to_string();
|
||
assert!(message.contains("1.20.1-not-on-disk"), "{message}");
|
||
assert!(message.contains("1.20.1-orphan"), "{message}");
|
||
|
||
let cycle = problems("cycle-a");
|
||
let message = cycle.first().expect("一条问题").to_string();
|
||
assert!(message.contains("cycle-a"), "{message}");
|
||
assert!(message.contains("cycle-b"), "{message}");
|
||
}
|
||
|
||
/// 坏 JSON 要带**行列号**(用户才知道去改哪一行)。
|
||
///
|
||
/// 抓得住:把 serde 的错误文本换成一句「解析失败」(`line` 就没了)。
|
||
/// 抓不住:具体行列号对不对(不同 serde_json 版本的措辞会变,只断言关键字)。
|
||
#[test]
|
||
fn broken_json_message_carries_a_line_number() {
|
||
let root = TempRoot::new("badjson");
|
||
foreign_minecraft(&root.game_root());
|
||
let candidate = candidate_of(&root, "1.20.1-broken");
|
||
let message = candidate.problems.first().expect("一条问题").to_string();
|
||
assert!(message.contains("line"), "{message}");
|
||
assert!(message.contains("1.20.1-broken"), "{message}");
|
||
}
|
||
|
||
/// 清单 `id` 与目录名不一致 ⇒ 拒绝(启动时会按清单 id 找 jar,必然找不到)。
|
||
///
|
||
/// 抓得住:去掉 `ManifestIdMismatch` 这条判据后,这个版本会变成「可登记」。
|
||
/// 抓不住:真实启动是否真的失败(那需要真跑游戏,本 mission 不做)。
|
||
#[test]
|
||
fn manifest_id_must_match_the_directory_name() {
|
||
let root = TempRoot::new("idmismatch");
|
||
foreign_minecraft(&root.game_root());
|
||
let dir = root.game_root().join("versions/1.20.1-copy");
|
||
std::fs::create_dir_all(&dir).expect("建目录");
|
||
std::fs::write(
|
||
dir.join("1.20.1-copy.json"),
|
||
br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#,
|
||
)
|
||
.expect("写清单");
|
||
std::fs::write(dir.join("1.20.1-copy.jar"), b"jar").expect("写 jar");
|
||
|
||
let candidate = candidate_of(&root, "1.20.1-copy");
|
||
let codes: Vec<&str> = candidate
|
||
.problems
|
||
.iter()
|
||
.map(|problem| problem.code())
|
||
.collect();
|
||
assert_eq!(codes, vec!["manifest_id_mismatch"]);
|
||
}
|
||
|
||
/// 版本 id 不能当路径用(`../` / 盘符 / 结尾点号)⇒ 一条结构化问题,不去碰盘。
|
||
///
|
||
/// 抓得住:删掉 id 校验后 `inspect("../escape")` 会去读游戏根之外的路径(下面断言问题码)。
|
||
/// 抓不住:Windows 大小写不敏感之类的平台细节(与判据无关)。
|
||
#[test]
|
||
fn version_ids_that_cannot_be_paths_are_rejected() {
|
||
let root = TempRoot::new("badid");
|
||
foreign_minecraft(&root.game_root());
|
||
let escape = root.path().join("outside");
|
||
std::fs::create_dir_all(escape.join("1.20.1")).expect("建外部同名目录");
|
||
std::fs::write(
|
||
escape.join("1.20.1/1.20.1.json"),
|
||
br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#,
|
||
)
|
||
.expect("写外部清单");
|
||
|
||
// `versions/../../outside/1.20.1` 这种形状:目录真的存在(能解析出去),但 id 里含分隔符。
|
||
let error = adopt::inspect(&root.game(), &root.launcher(), "../../outside/1.20.1")
|
||
.expect_err("非法 id 应报错,而不是去读游戏根之外的目录");
|
||
assert_eq!(error.code(), "adopt_not_adoptable");
|
||
let codes: Vec<&str> = error
|
||
.problems()
|
||
.iter()
|
||
.map(|problem| problem.code())
|
||
.collect();
|
||
assert_eq!(codes, vec!["version_id_invalid"], "{error}");
|
||
|
||
for bad in ["1.20.1.", "1.20.1 ", "C:", "a/b"] {
|
||
let error =
|
||
adopt::inspect(&root.game(), &root.launcher(), bad).expect_err("非法 id 应报错");
|
||
assert!(
|
||
error
|
||
.problems()
|
||
.iter()
|
||
.any(|problem| problem.code() == "version_id_invalid"),
|
||
"{bad:?} 应被当成非法 id:{error}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 版本目录是指向游戏根**之外**的符号链接 ⇒ 拒绝不跟随(也不读它)。
|
||
///
|
||
/// 抓得住:`ensure_inside` 不比较解析后的真实路径(那样 `1.20.1-link` 会变成「可登记」)。
|
||
/// 抓不住 / 已知局限:
|
||
/// - 本机不允许建符号链接(Windows 未开开发者模式、或非管理员)时这条会打印 `[link-guard]`
|
||
/// 并**跳过**——显式记录而不是静静通过;设 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 则建不出来**必红**;
|
||
/// - 目录联接(junction)由下面单独那条用例覆盖(不需要管理员权限,是更常见的形态)。
|
||
#[test]
|
||
fn a_symlinked_version_pointing_outside_the_game_root_is_rejected() {
|
||
let root = TempRoot::new("symlink");
|
||
foreign_minecraft(&root.game_root());
|
||
let outside = root.path().join("other-launcher");
|
||
std::fs::create_dir_all(outside.join("1.20.1")).expect("建外部版本目录");
|
||
std::fs::write(
|
||
outside.join("1.20.1/1.20.1.json"),
|
||
br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#,
|
||
)
|
||
.expect("写外部清单");
|
||
let link = root.game_root().join("versions/1.20.1-link");
|
||
// 建不出来时:非强制模式打印一行并跳过;`ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 必红(R4)。
|
||
if !crate::fixture::link_guard(
|
||
crate::fixture::try_symlink_dir(&outside.join("1.20.1"), &link),
|
||
"根外符号链接",
|
||
) {
|
||
return;
|
||
}
|
||
|
||
let candidate = candidate_of(&root, "1.20.1-link");
|
||
let codes: Vec<&str> = candidate
|
||
.problems
|
||
.iter()
|
||
.map(|problem| problem.code())
|
||
.collect();
|
||
assert!(
|
||
codes.contains(&"outside_game_root"),
|
||
"越界链接必须被点名:{codes:?}"
|
||
);
|
||
assert!(!candidate.adoptable(), "越界链接的版本不许登记");
|
||
}
|
||
|
||
/// 游戏根内的符号链接(指向同一棵树里的真实版本目录)**不**拒绝:那是合法的省盘做法。
|
||
///
|
||
/// 抓得住:`check_symlink` 一刀切拒绝所有符号链接(这条会红)。
|
||
/// 抓不住:本机建不出链接时同上跳过(打印 `[link-guard]`,强制模式下必红)。
|
||
#[test]
|
||
fn a_symlink_inside_the_game_root_is_allowed() {
|
||
let root = TempRoot::new("symlink-in");
|
||
foreign_minecraft(&root.game_root());
|
||
// 真实版本目录放在根内的 `hidden/` 下(清单 id 与目录名一致),再在 `versions/` 里挂一条链接。
|
||
let real = root.game_root().join("hidden/1.20.1-thin");
|
||
std::fs::create_dir_all(&real).expect("建真实版本目录");
|
||
std::fs::write(
|
||
real.join("1.20.1-thin.json"),
|
||
br#"{"id":"1.20.1-thin","type":"release","mainClass":"x","libraries":[]}"#,
|
||
)
|
||
.expect("写清单");
|
||
let link = root.game_root().join("versions/1.20.1-thin");
|
||
if !crate::fixture::link_guard(
|
||
crate::fixture::try_symlink_dir(&real, &link),
|
||
"根内符号链接",
|
||
) {
|
||
return;
|
||
}
|
||
let candidate = candidate_of(&root, "1.20.1-thin");
|
||
assert!(
|
||
candidate.adoptable(),
|
||
"根内链接不该被拒:{:?}",
|
||
candidate.problems
|
||
);
|
||
}
|
||
|
||
/// `versions/` 不存在 = 空结果(新数据根不是错误),但**不存在的游戏根不会被建出来**。
|
||
///
|
||
/// 抓得住:`scan` 顺手 `create_dir_all(versions)`(下面的断言会红),或把「不存在」当错误。
|
||
/// 抓不住:读目录失败(ENOTDIR/EACCES)那条分支——真造一个不可读目录在 Windows 上不可靠,
|
||
/// 那一条由代码审读把关。
|
||
#[test]
|
||
fn a_missing_versions_dir_is_empty_not_an_error() {
|
||
let root = TempRoot::new("noversions");
|
||
let scan = adopt::scan(&root.game(), &root.launcher()).expect("应成功");
|
||
assert!(scan.candidates.is_empty());
|
||
assert!(
|
||
!root.game_root().exists(),
|
||
"扫描不该顺手建游戏根目录:{}",
|
||
root.game_root().display()
|
||
);
|
||
}
|
||
|
||
/// Windows 目录联接(junction)指向根外 ⇒ **同样**拒绝,而且不去读那个目录。
|
||
///
|
||
/// 为什么要单列:junction 不是 `is_symlink`,只看链接类型的实现会放行它——而 junction
|
||
/// **不需要管理员权限**就能建,是把别人版本目录挂进 `versions/` 最常见的形态。
|
||
///
|
||
/// 抓得住:
|
||
/// - 判据退回 `is_symlink`(除 Windows junction 外全都看不到,此时候选会变成「可登记」,第一条断言红);
|
||
/// - 判据排到**解析清单之后**:根外那份清单是合法 JSON 且 id 与目录名一致,一旦走到解析,
|
||
/// `manifest_id` 就会被填上(第二条断言红)——所以它至少能证明「没解析过根外清单」;
|
||
/// - 建不出链接时走 `fixture::link_guard`:非强制模式打印 `[link-guard]` 并跳过,
|
||
/// 强制模式(`ULL_ADOPT_TEST_SYMLINK_REQUIRE=1`)**必红**(真值表见 `fixture::link_gate_truth_table`)。
|
||
///
|
||
/// 抓不住(证据边界,别把它读大):**只 stat/只 open 不解析**的读取这里看不出来
|
||
/// (`manifest_id` 为 `None` 只说明没解析过,不说明没碰过文件);「边界检查排在
|
||
/// `read_manifest` 之前」这条由 `inspect.rs::check` 的代码顺序保证,靠人读。
|
||
/// 也抓不住检查与读取之间的 TOCTOU(本模块不是并发/对抗入口)。
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn a_junction_pointing_outside_is_rejected_without_reading_it() {
|
||
let root = TempRoot::new("junction");
|
||
foreign_minecraft(&root.game_root());
|
||
// 逐段 join:`cmd` / `mklink` 会把正斜杠当开关,拼成一串带 `/` 的路径会被判「无效参数」。
|
||
let outside = root.path().join("other-launcher").join("1.20.1-junction");
|
||
std::fs::create_dir_all(&outside).expect("建外部版本目录");
|
||
std::fs::write(
|
||
outside.join("1.20.1-junction.json"),
|
||
br#"{"id":"1.20.1-junction","type":"release","mainClass":"x","libraries":[]}"#,
|
||
)
|
||
.expect("写外部清单");
|
||
let link = root.game_root().join("versions").join("1.20.1-junction");
|
||
if !crate::fixture::link_guard(
|
||
crate::fixture::try_junction(&outside, &link),
|
||
"根外目录联接",
|
||
) {
|
||
return;
|
||
}
|
||
|
||
let candidate = candidate_of(&root, "1.20.1-junction");
|
||
let codes: Vec<&str> = candidate
|
||
.problems
|
||
.iter()
|
||
.map(|problem| problem.code())
|
||
.collect();
|
||
assert!(
|
||
codes.contains(&"outside_game_root"),
|
||
"联接越界必须被点名:{codes:?} / {:?}",
|
||
candidate.problems
|
||
);
|
||
assert!(!candidate.adoptable(), "越界联接的版本不许登记");
|
||
assert!(
|
||
candidate.manifest_id.is_none() && candidate.inherits.is_empty(),
|
||
"拒绝之后不许再读根外那份清单:{:?}",
|
||
candidate.manifest_id
|
||
);
|
||
}
|