Files
Unlucky-Launcher/crates/core/tests/adopt/surface_cases.rs
T
AskaEth 8110cde3f5 fix(game): 复审 r1 五项——运行目录不许被两实例共用 + 配置写面越界 + versions 断链 + 链接守卫反转 + 快照漏目录
复审 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 重解析点仍列边界。
2026-11-09 23:13:57 +08:00

155 lines
7.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 写盘面与入口可用性:**配置目录被链出去**必须拒绝(R2)、**versions 入口断链**必须具名报错(R3)。
//!
//! 两类都属于「本 mission 承诺的东西被链接绕过」:前者是「只写自己的配置」被
//! `config/instances` 的目录联接打穿(会替别人的启动器在 `.minecraft/config` 里造 UUID 文件),
//! 后者是「坏东西不能当空列表」被 `read_dir` 的 NotFound 打穿(断链 junction 与「真没有」同态)。
//!
//! 两条都用**真链接夹具**;建不出链接时走 `fixture::link_guard`(强制模式下必红,见 R4)。
use unlucky_core::game::adopt::{self, AdoptTarget};
use crate::fixture::{
assert_unchanged, foreign_minecraft, link_guard, request, snapshot, try_link_dir, TempRoot,
};
/// `config/instances` 被目录联接指到游戏根的 `config/`:adopt 必须先拒绝,且游戏根零写入。
///
/// 抓得住:
/// - 只核运行目录、不核配置写面(那样这次登记会 exit0,并在 `.minecraft/config/<uuid>.json`
/// 多出一个文件——`before/after` 全节点快照与「外部 config 目录为空」两条都会红);
/// - `plan`(dry-run)也放行(第四条断言红:预检说「可以」,真写才失败);
/// - 拒绝时先写了目录/配置(快照与目录列举都会红)。
///
/// 抓不住:检查与写入之间的 TOCTOU(本模块不是并发/对抗入口)。
#[cfg(windows)]
#[test]
fn a_junction_on_the_instances_dir_must_not_write_into_the_game_root() {
use crate::fixture::try_junction;
let root = TempRoot::new("cfg-instances-junction");
foreign_minecraft(&root.game_root());
let game_config = root.game_root().join("config");
std::fs::create_dir_all(&game_config).expect("建别人的 config 目录");
let instances_dir = root.path().join(".ULL").join("config").join("instances");
std::fs::create_dir_all(instances_dir.parent().expect("config 目录")).expect("建 .ULL/config");
if !link_guard(
try_junction(&game_config, &instances_dir),
"config/instances 目录联接",
) {
return;
}
let before = snapshot(&root.game_root());
// 别人的 `config/` 里本来就有文件(`example.toml` 是玩家配置):判据是**没有新增条目**。
let config_before = listing(&game_config);
let plan_error = adopt::plan(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("配置写面被链走时,dry-run 也必须报错");
assert_eq!(plan_error.code(), "adopt_config_surface_unsafe");
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("配置写面被链走时必须拒绝登记");
assert_eq!(error.code(), "adopt_config_surface_unsafe");
let after = snapshot(&root.game_root());
assert_unchanged(&before, &after, "拒绝之后(游戏根零写入)");
assert_eq!(
config_before,
listing(&game_config),
"绝不许在别人的 .minecraft/config 里新增文件(尤其 UUID 配置)"
);
assert!(
!listing(&game_config)
.iter()
.any(|name| name.ends_with(".json") && name.len() == 41),
"不该出现 UUID 形状的 json:{:?}",
listing(&game_config)
);
}
/// `config/` 整层被链到游戏根:同样拒绝(判定粒度到**每一个**写盘目标,不只看 `instances`)。
///
/// 抓得住:只检查 `config/instances` 而不检查 `config/` 与 `profiles.json`(那样这条会 exit0)。
/// 抓不住:跨卷/跨盘的整体迁移(`<.ULL>` 自己是联接)——那是允许的,见 `run_dir` 模块文档。
#[test]
fn a_linked_config_dir_must_not_write_into_the_game_root() {
let root = TempRoot::new("cfg-dir-link");
foreign_minecraft(&root.game_root());
let game_config = root.game_root().join("config");
std::fs::create_dir_all(&game_config).expect("建别人的 config 目录");
let ull = root.path().join(".ULL");
std::fs::create_dir_all(&ull).expect("建 .ULL");
let config_dir = ull.join("config");
if !link_guard(try_link_dir(&game_config, &config_dir), "config 目录链接") {
return;
}
let before = snapshot(&root.game_root());
let error = adopt::adopt(
&root.game(),
&root.launcher(),
&request("1.20.1", AdoptTarget::InstanceDir),
)
.expect_err("config/ 被链走时必须拒绝登记");
assert_eq!(error.code(), "adopt_config_surface_unsafe");
let after = snapshot(&root.game_root());
assert_unchanged(&before, &after, "拒绝之后(游戏根零写入)");
}
/// `versions/` 入口**存在但断链**(目标被移走)⇒ 具名报错,而不是「这个数据根还没有版本」。
///
/// 抓得住:`read_dir` 的 `NotFound` 一律当空列表(旧实现;Windows 断链 junction 正是这个形状)
/// ——第一条断言会红成「成功空列表」。
/// 抓不住:`versions/` 是普通文件的情形(走 `GameError::io`,错误码不同,靠人读)。
#[test]
fn a_dangling_versions_entry_is_an_error_not_an_empty_scan() {
let root = TempRoot::new("dangling-versions");
std::fs::create_dir_all(root.game_root()).expect("建游戏根");
let moved = root.path().join("moved-versions");
std::fs::create_dir_all(moved.join("1.20.1")).expect("建将被移走的 versions");
let versions = root.game_root().join("versions");
if !link_guard(try_link_dir(&moved, &versions), "versions 入口链接") {
return;
}
// 目标被移走 ⇒ 入口成为断链;lstat 仍然看得见它。
std::fs::remove_dir_all(&moved).expect("移走目标");
assert!(
std::fs::symlink_metadata(&versions).is_ok(),
"夹具有问题:断链入口应当仍能被 lstat 看到"
);
let error = adopt::scan(&root.game(), &root.launcher())
.expect_err("断链的 versions 入口必须报错,不能当空列表");
assert_eq!(error.code(), "adopt_versions_unreadable", "{error}");
}
/// `versions` 真不存在 ⇒ 仍然是空结果(别把「新数据根」误报成故障)。
///
/// 抓得住:为了修 R3 把所有 NotFound 都当错误(这条会红)。
/// 抓不住:真正不可读(EACCES)那一支——Windows 上造不可读目录不可靠。
#[test]
fn a_truly_missing_versions_dir_stays_empty() {
let root = TempRoot::new("no-versions");
std::fs::create_dir_all(root.game_root()).expect("建空游戏根");
let scan = adopt::scan(&root.game(), &root.launcher()).expect("真不存在 ⇒ 空结果");
assert!(scan.candidates.is_empty());
}
/// 目录里直接子项的名字(排序后,用于「有没有新增」的判据)。
fn listing(dir: &std::path::Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.expect("读目录")
.filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
.collect();
names.sort();
names
}