Files
Unlucky-Launcher/crates/core/tests/adopt/fixture.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

350 lines
13 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.
//! 真实磁盘夹具:一份「别的启动器铺好的」`.minecraft` + 全量快照工具。
//!
//! # 为什么必须是真盘
//!
//! 本 mission 的核心判据是**「登记前后外部文件一个字节都不变」**。内存里的假目录证明不了这件事
//! ——mtime、目录项、符号链接都只有真文件系统才存在。所以每个用例都在 `std::env::temp_dir()`
//! 下建一棵真目录树,`Drop` 时整棵删掉。
//!
//! # 夹具里放了什么(逐项都有用意)
//!
//! | 路径 | 用意 |
//! | --- | --- |
//! | `versions/1.20.1/` | 正常版本(清单 + jar) |
//! | `versions/1.20.1-fabric-0.15.0/` | 正常版本,`inheritsFrom` 指向 1.20.1(父链闭合) |
//! | `versions/1.20.1-broken/` | 清单不是合法 JSON |
//! | `versions/1.20.1-orphan/` | `inheritsFrom` 指向不存在的父 |
//! | `versions/cycle-a/` `versions/cycle-b/` | 互相继承(成环) |
//! | `versions/1.20.1-nomanifest/` | 只有目录、没有同名清单 |
//! | `libraries/` `assets/` | 恒共享层(登记不该碰) |
//! | `mods/` `saves/` `config/` | 玩家数据(登记不该碰) |
//! | `launcher_profiles.json` | 别的启动器的元数据(连读都不该读,更不该写) |
//!
//! 快照工具在 `snapshot.rs`(**全节点**:目录/文件/链接各记形状与 mtime,读盘失败即失败),
//! 本模块只做 `pub use`,测试统一从 `crate::fixture::{snapshot, assert_unchanged, …}` 取。
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use unlucky_core::game::adopt::{AdoptRequest, AdoptTarget};
use unlucky_core::game::GameLayout;
pub use super::snapshot::{assert_unchanged, snapshot, snapshot_opt, Node};
use unlucky_core::platform::layout::LauncherLayout;
/// 唯一临时目录守卫(`drop` 时递归删除)。
pub struct TempRoot {
path: PathBuf,
}
impl TempRoot {
/// 建一个唯一临时根。
pub fn new(tag: &str) -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"ull-adopt-{tag}-{}-{nanos}-{seq}",
std::process::id()
));
std::fs::create_dir_all(&path).expect("临时根应可创建");
Self { path }
}
/// 临时根路径。
pub fn path(&self) -> &Path {
&self.path
}
/// `.minecraft`(游戏根)。
pub fn game_root(&self) -> PathBuf {
self.path.join(".minecraft")
}
/// 游戏布局。
pub fn game(&self) -> GameLayout {
GameLayout::new(self.game_root())
}
/// 启动器数据布局(`<root>/.ULL`)。
pub fn launcher(&self) -> LauncherLayout {
LauncherLayout::new(self.path.join(".ULL"))
}
}
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// 造一份「别的启动器已经铺好的」`.minecraft`(见模块文档的表)。
pub fn foreign_minecraft(root: &Path) {
let versions = root.join("versions");
// 正常版本:清单 + jar。
version_with(&versions, "1.20.1", MANIFEST_VANILLA, true);
// 正常版本:带父链(fabric 形态:只有清单、没有自己的 jar)。
version_with(&versions, "1.20.1-fabric-0.15.0", MANIFEST_FABRIC, false);
// 清单坏掉。
let broken = versions.join("1.20.1-broken");
std::fs::create_dir_all(&broken).expect("建坏清单目录");
std::fs::write(broken.join("1.20.1-broken.json"), b"{ this is not json").expect("写坏清单");
// 缺父版本。
version_with(&versions, "1.20.1-orphan", MANIFEST_ORPHAN, true);
// 成环。
version_with(&versions, "cycle-a", MANIFEST_CYCLE_A, true);
version_with(&versions, "cycle-b", MANIFEST_CYCLE_B, true);
// 只有目录、没有清单。
std::fs::create_dir_all(versions.join("1.20.1-nomanifest")).expect("建无清单目录");
// 恒共享层:库与资源(登记一个字节都不该动)。
write_file(
&root.join("libraries/com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar"),
b"library-bytes",
);
write_file(&root.join("assets/indexes/5.json"), b"{\"objects\":{}}");
write_file(
&root.join("assets/objects/ab/abcdef0123456789abcdef0123456789abcdef01"),
b"asset-bytes",
);
// 玩家数据。
write_file(&root.join("mods/jei.jar"), b"mod-bytes");
write_file(&root.join("saves/World/level.dat"), b"save-bytes");
write_file(&root.join("config/example.toml"), b"config = 1\n");
// 别的启动器的元数据(登记不该读、更不该写)。
write_file(
&root.join("launcher_profiles.json"),
br#"{"profiles":{"1.20.1":{"name":"1.20.1","lastVersionId":"1.20.1"}}}"#,
);
}
fn version_with(versions: &Path, id: &str, manifest: &str, jar: bool) {
let dir = versions.join(id);
std::fs::create_dir_all(&dir).expect("建版本目录");
std::fs::write(dir.join(format!("{id}.json")), manifest.as_bytes()).expect("写清单");
if jar {
std::fs::write(dir.join(format!("{id}.jar")), b"jar-bytes").expect("写 jar");
}
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("建父目录");
}
std::fs::write(path, bytes).expect("写文件");
}
/// 原版形态的最小清单(`downloads.client` 有值、无继承)。
const MANIFEST_VANILLA: &str = r#"{
"id": "1.20.1",
"type": "release",
"mainClass": "net.minecraft.client.main.Main",
"assets": "5",
"assetIndex": { "id": "5" },
"arguments": {
"game": ["--username", "${auth_player_name}", "--version", "${version_name}",
"--gameDir", "${game_directory}"],
"jvm": ["-Djava.library.path=${natives_directory}", "-cp", "${classpath}"]
},
"libraries": [],
"downloads": { "client": { "size": 9, "url": "http://example.invalid/client.jar" } }
}"#;
/// Fabric 形态:`inheritsFrom` 指 1.20.1(父链闭合)。
const MANIFEST_FABRIC: &str = r#"{
"id": "1.20.1-fabric-0.15.0",
"type": "release",
"inheritsFrom": "1.20.1",
"mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient",
"libraries": []
}"#;
/// 缺父版本(父 id 在盘上不存在)。
const MANIFEST_ORPHAN: &str = r#"{
"id": "1.20.1-orphan",
"type": "release",
"inheritsFrom": "1.20.1-not-on-disk",
"mainClass": "net.minecraft.client.main.Main",
"libraries": []
}"#;
/// 环的一侧。
const MANIFEST_CYCLE_A: &str = r#"{
"id": "cycle-a",
"type": "release",
"inheritsFrom": "cycle-b",
"mainClass": "net.minecraft.client.main.Main",
"libraries": []
}"#;
/// 环的另一侧。
const MANIFEST_CYCLE_B: &str = r#"{
"id": "cycle-b",
"type": "release",
"inheritsFrom": "cycle-a",
"mainClass": "net.minecraft.client.main.Main",
"libraries": []
}"#;
/// 一次登记请求(测试用:固定 `created_at`,名字缺省 = 版本 id)。
pub fn request(version_id: &str, target: AdoptTarget) -> AdoptRequest {
AdoptRequest {
version_id: version_id.to_string(),
name: None,
target,
created_at: Some("2026-11-09T00:00:00.000Z".to_string()),
}
}
/// 建目录符号链接(`true` = 建成)。Windows 上未开开发者模式/非管理员时可能失败。
pub fn try_symlink_dir(target: &Path, link: &Path) -> bool {
#[cfg(unix)]
let created = std::os::unix::fs::symlink(target, link).is_ok();
#[cfg(windows)]
let created = std::os::windows::fs::symlink_dir(target, link).is_ok();
created
}
/// 用 `mklink /J` 建目录联接(junction 不需要管理员权限;建不出来返回 `false`)。
///
/// 只在 Windows 上有意义:junction 不是 `is_symlink`,是把别人版本目录挂进 `versions/` 最常见的
/// 形态,所以越界判据必须能看见它——这条夹具就是用来证明这点的。
#[cfg(windows)]
pub fn try_junction(target: &Path, link: &Path) -> bool {
// `cmd /c mklink /J <link> <target>`:三个参数分开传,别拼成一整条(拼成一串会被 cmd 当语法错),
// 路径也必须**逐段 join**(`cmd` 把正斜杠当开关)。
let output = std::process::Command::new("cmd")
.args(["/c", "mklink", "/J"])
.arg(link)
.arg(target)
.output();
match output {
Ok(output) if output.status.success() && link.is_dir() => true,
Ok(output) => {
eprintln!(
"[junction] mklink /J {} {} 失败({:?}):{}{}",
link.display(),
target.display(),
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
false
}
Err(error) => {
eprintln!("[junction] 调不起 cmd:{error}");
false
}
}
}
/// 建目录链接:Windows 先试符号链接、再试联接;其它平台只有符号链接。
pub fn try_link_dir(target: &Path, link: &Path) -> bool {
if try_symlink_dir(target, link) {
return true;
}
#[cfg(windows)]
{
try_junction(target, link)
}
#[cfg(not(windows))]
{
false
}
}
/// 链接夹具建不出来时怎么走(**真值表**,复审 r1 的 R4 抓的就是这张表被写反)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LinkGate {
/// 建成了:照常跑断言。
Run,
/// 没建成且**没要求强制**:打印一行记录后跳过(不静默)。
Skip,
/// 没建成且 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1`:**必红**,不许空跑。
Fail,
}
/// 纯判据:`(建成了吗, 是否强制)` → 怎么走。
#[must_use]
pub fn link_gate(created: bool, required: bool) -> LinkGate {
match (created, required) {
(true, _) => LinkGate::Run,
(false, true) => LinkGate::Fail,
(false, false) => LinkGate::Skip,
}
}
/// `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 时链接夹具**不许**空跑。
#[must_use]
pub fn links_required() -> bool {
std::env::var("ULL_ADOPT_TEST_SYMLINK_REQUIRE")
.as_deref()
.map(str::trim)
== Ok("1")
}
/// 链接夹具的守卫:返回 `true` = 继续跑断言;`false` = 已显式跳过(调用方 `return`)。
///
/// `required` 由环境变量决定([`links_required`]);判据本身是 [`link_gate`]。
pub fn link_guard(created: bool, what: &str) -> bool {
link_guard_with(created, links_required(), what)
}
/// 同 [`link_guard`],但 `required` 显式传入(测试要能**执行**强制分支,而不靠改进程环境)。
///
/// # Panics
///
/// `required` 且没建成链接/联接:**红**(这是复审 r1 的 R4:旧写法把方向写反了,
/// 强制模式下建不出来反而直接「成功」返回)。
pub fn link_guard_with(created: bool, required: bool, what: &str) -> bool {
match link_gate(created, required) {
LinkGate::Run => true,
LinkGate::Skip => {
eprintln!(
"[link-guard] {what}:本机建不出符号链接/目录联接,已显式跳过这条用例; 设 ULL_ADOPT_TEST_SYMLINK_REQUIRE=1 可让它必跑(建不出来即红)"
);
false
}
LinkGate::Fail => panic!(
"[link-guard] {what}:ULL_ADOPT_TEST_SYMLINK_REQUIRE=1,但本机建不出符号链接/目录联接 ——这条用例不许空跑,判红"
),
}
}
/// 真值表的回归:**强制模式建不出来必须致命**(R4 的反转就是这张表写错)。
///
/// 抓得住:把 `(false, true)` 判成 `Skip`(旧实现的写法)、或把 `(false, false)` 判成 `Fail`。
/// 抓不住:真实建链接是否成功(那由各链接用例自己走 [`link_guard`])。
#[test]
fn link_gate_truth_table() {
assert_eq!(link_gate(true, false), LinkGate::Run);
assert_eq!(link_gate(true, true), LinkGate::Run);
assert_eq!(link_gate(false, false), LinkGate::Skip);
assert_eq!(
link_gate(false, true),
LinkGate::Fail,
"强制模式下建不出链接必须红"
);
}
/// **执行**守卫的三个分支(不只测纯判据):Run 继续、Skip 返回 false、Fail panic。
///
/// 抓得住:把 `Fail` 分支写成 `return false`(`catch_unwind` 捕不到 panic,最后一条断言红);
/// 把 `Skip` 写成 panic(第一条 `catch_unwind` 不再 ok)。
/// 抓不住:真实建链接是否成功(各链接用例自己走 [`link_guard`]);也不依赖进程级环境变量,
/// 因此可与并行用例共存。
#[test]
fn link_guard_executes_all_three_branches() {
assert!(link_guard_with(true, false, "已建成"));
assert!(
std::panic::catch_unwind(|| link_guard_with(false, false, "非强制模式 · 建不出")).is_ok(),
"非强制模式不许 panic"
);
assert!(!link_guard_with(false, false, "非强制模式 · 建不出"));
let forced = std::panic::catch_unwind(|| link_guard_with(false, true, "强制模式 · 建不出"));
assert!(forced.is_err(), "强制模式下建不出来必须 panic(R4)");
}