Files
Unlucky-Launcher/crates/core/tests/adopt/fixture.rs
T
AskaEth a5d1dec495
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
2026-11-11 21:41:38 +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)");
}