release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
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
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
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
commit
a5d1dec495
1580 files changed
+320938
No files matched your search
@@ -0,0 +1,221 @@
|
||||
//! 运行目录**碰撞**:slug 同名 / 大小写不敏感 / 「空目录已被实例占着」/ 「认领目录还不存在」。
|
||||
//!
|
||||
//! 复审 r1 的 R1:旧实现只比展示名,第二次登记看到「目录是空的」就复用了 ⇒ 两个从未选择
|
||||
//! shared/custom 的实例共用一个运行目录(`mods`/`saves` 互相污染)。
|
||||
//! 判据必须是「有没有实例占着这个**真实**目录」,而不是「目录空不空」。
|
||||
|
||||
use unlucky_core::game::adopt::{self, AdoptTarget};
|
||||
use unlucky_core::game::install::instance::read_instance_config;
|
||||
use unlucky_core::game::install::pack_dir;
|
||||
|
||||
use crate::fixture::{foreign_minecraft, request, TempRoot};
|
||||
|
||||
/// **slug 碰撞**:`Better MC` 与 `Better-MC` 派生到同一个目录名 —— 两个实例必须落在不同目录。
|
||||
///
|
||||
/// 复审 r1 的 R1:旧实现只比展示名,第二次登记看到「目录是空的」就复用了,于是两个从未选择
|
||||
/// shared/custom 的实例共用一个运行目录(`mods`/`saves` 互相污染)。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - 判据只看目录空不空、不看已有实例占没占(两条 `run_dir` 不同的断言会红);
|
||||
/// - 顺延目录没建出来 / 提示里没点出撞的是谁。
|
||||
///
|
||||
/// 抓不住:`pack_dir::instance_dir_name` 本身把两个名字折成同一个目录(那是既有行为,
|
||||
/// 本 mission 不改它,只保证**登记侧**不共用)。
|
||||
#[test]
|
||||
fn slug_colliding_names_do_not_share_a_run_dir() {
|
||||
let root = TempRoot::new("slug-collide");
|
||||
foreign_minecraft(&root.game_root());
|
||||
|
||||
let mut first = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
first.name = Some("Better MC".to_string());
|
||||
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("第一次登记");
|
||||
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
second.name = Some("Better-MC".to_string());
|
||||
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记");
|
||||
|
||||
assert_ne!(a.run_dir, b.run_dir, "两个实例不许共用同一个运行目录");
|
||||
assert!(
|
||||
a.run_dir.is_dir() && b.run_dir.is_dir(),
|
||||
"两个目录都要真实存在"
|
||||
);
|
||||
let bumped = b
|
||||
.run_dir
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned());
|
||||
assert!(
|
||||
bumped.as_deref().is_some_and(|name| name.ends_with("-2")),
|
||||
"撞车后应顺延成 -2:{:?}",
|
||||
b.run_dir
|
||||
);
|
||||
assert!(
|
||||
b.notes.iter().any(|note| note.contains("同一个物理目录")),
|
||||
"必须把「撞了谁」说出来:{:?}",
|
||||
b.notes
|
||||
);
|
||||
|
||||
// 两份实例设置各自指向自己的目录(不是都指第一个)。
|
||||
let config_a = read_instance_config(&root.launcher(), &a.instance_id)
|
||||
.expect("读设置")
|
||||
.expect("有设置文件");
|
||||
let config_b = read_instance_config(&root.launcher(), &b.instance_id)
|
||||
.expect("读设置")
|
||||
.expect("有设置文件");
|
||||
assert_eq!(config_a.game_dir.as_deref(), Some(a.run_dir.as_path()));
|
||||
assert_eq!(config_b.game_dir.as_deref(), Some(b.run_dir.as_path()));
|
||||
}
|
||||
|
||||
/// **大小写碰撞**(Windows/macOS):`Foo` 与 `foo` 是同一个物理目录 ⇒ 同样必须顺延。
|
||||
///
|
||||
/// 抓得住:`dir_key` 不做大小写归一(Windows 上第二次登记会复用同一个目录)。
|
||||
/// 抓不住:Linux(大小写敏感,两条目录本来就不同——那里这条不适用,见 `cfg`)。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn names_differing_only_in_case_do_not_share_a_run_dir() {
|
||||
let root = TempRoot::new("case-collide");
|
||||
foreign_minecraft(&root.game_root());
|
||||
|
||||
let mut first = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
first.name = Some("Foo".to_string());
|
||||
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("第一次登记");
|
||||
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
second.name = Some("foo".to_string());
|
||||
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记");
|
||||
|
||||
assert_ne!(a.run_dir, b.run_dir, "大小写不同不是「不同目录」的理由");
|
||||
let a_real = a.run_dir.canonicalize().expect("真实路径 A");
|
||||
let b_real = b.run_dir.canonicalize().expect("真实路径 B");
|
||||
assert_ne!(a_real, b_real, "两个运行目录的真实路径必须不同");
|
||||
}
|
||||
|
||||
/// 空目录**已被别的实例占着** ⇒ 也不能当「失败残留」复用,要顺延。
|
||||
///
|
||||
/// 这是 R1 的另一半:判据必须是「有没有实例占着」,而不是「目录空不空」。
|
||||
///
|
||||
/// 抓得住:只按 `directory_occupied` 判(第一次登记建出的空目录会被第二次白白复用)。
|
||||
/// 抓不住:目录非空且无人认领的情形(那条保持 `TargetNotEmpty`,见下一条用例)。
|
||||
#[test]
|
||||
fn an_empty_dir_owned_by_another_instance_is_not_reused() {
|
||||
let root = TempRoot::new("owned-empty");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let mut first = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
first.name = Some("Better MC".to_string());
|
||||
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("第一次登记");
|
||||
// 目录此刻是空的(新建、还没启动过游戏)——旧判据会认为「可复用」。
|
||||
let entries_before = std::fs::read_dir(&a.run_dir).expect("读目录").count();
|
||||
assert_eq!(entries_before, 0, "夹具前提:这个目录此刻是空的");
|
||||
|
||||
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
second.name = Some("Better-MC".to_string());
|
||||
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记");
|
||||
assert_ne!(b.run_dir, a.run_dir);
|
||||
}
|
||||
|
||||
/// **认领的目录还不存在**时也必须能比对出碰撞。
|
||||
///
|
||||
/// 场景:第一个实例用 `--isolation custom --game-dir <某路径>` 登记(custom 档**不创建**目录),
|
||||
/// 那条路径恰好等于第二个实例缺省派生出来的目录名。第二个实例看到的目录「不存在、非空判据为假」,
|
||||
/// 但它已经被第一个实例认领了。
|
||||
///
|
||||
/// 抓得住:`claims`/`dir_key` 只在「路径存在」时才比较(那样第二条 `run_dir` 相同的断言会红);
|
||||
/// 抓不住:跨卷/大小写之外的别名(例如 8.3 短名),本判据不覆盖。
|
||||
#[test]
|
||||
fn a_claim_on_a_not_yet_existing_dir_still_collides() {
|
||||
let root = TempRoot::new("claim-missing");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let default_dir = pack_dir::instance_dir(&root.launcher(), "Better MC");
|
||||
assert!(!default_dir.exists(), "夹具前提:这个目录还不存在");
|
||||
|
||||
let mut first = request("1.20.1", AdoptTarget::Custom(default_dir.clone()));
|
||||
first.name = Some("Better MC".to_string());
|
||||
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("custom 登记成功");
|
||||
assert_eq!(a.run_dir, default_dir);
|
||||
assert!(!default_dir.exists(), "custom 目标不该被创建");
|
||||
|
||||
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
second.name = Some("Better-MC".to_string());
|
||||
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("第二次登记成功");
|
||||
|
||||
assert_ne!(b.run_dir, default_dir, "认领侧不存在也要能比对出碰撞");
|
||||
assert!(
|
||||
b.notes.iter().any(|note| note.contains("同一个物理目录")),
|
||||
"碰撞要写进 notes:{:?}",
|
||||
b.notes
|
||||
);
|
||||
}
|
||||
|
||||
/// **Win32 尾点/尾空格别名**:`custom` 认领 `Foo.`(目录还没建)与缺省派生 `Foo` 是**同一个物理目录**。
|
||||
///
|
||||
/// 复审 r2 的 R2.1:普通路径的尾点/尾空格会被 Win32 折叠,旧实现把尚不存在的尾段原样拼进比较键,
|
||||
/// 于是第二次默认登记仍拿到 `Foo`(两次都 exit0、两个 UUID、同一物理目录)。
|
||||
///
|
||||
/// 抓得住:`dir_key` 只做分隔符/大小写归一(`Foo.` 与 `Foo` 键不等,第二条断言红)。
|
||||
///
|
||||
/// 抓不住:verbatim 路径(`\\?\…`)——那种写法**不**折叠,是另一个物理目录,见下一条用例。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_trailing_dot_claim_still_collides_with_the_derived_dir() {
|
||||
for suffix in [".", " "] {
|
||||
let root = TempRoot::new("win32-alias");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let claimed = pack_dir::instance_dir(&root.launcher(), &format!("Foo{suffix}"))
|
||||
.with_file_name(format!("Foo{suffix}"));
|
||||
assert!(!claimed.exists(), "夹具前提:认领目录还不存在");
|
||||
|
||||
let mut first = request("1.20.1", AdoptTarget::Custom(claimed.clone()));
|
||||
first.name = Some("First".to_string());
|
||||
let a = adopt::adopt(&root.game(), &root.launcher(), &first).expect("custom 登记成功");
|
||||
|
||||
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
second.name = Some("Foo".to_string());
|
||||
let b = adopt::adopt(&root.game(), &root.launcher(), &second)
|
||||
.expect("第二次默认登记应成功(顺延到 -2,而不是复用同一目录)");
|
||||
|
||||
assert_ne!(
|
||||
b.run_dir, a.run_dir,
|
||||
"尾点/尾空格别名不许让两个实例共用一个运行目录(suffix={suffix:?})"
|
||||
);
|
||||
assert!(
|
||||
b.run_dir
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().ends_with("-2"))
|
||||
.unwrap_or(false),
|
||||
"应顺延成 -2:{}",
|
||||
b.run_dir.display()
|
||||
);
|
||||
assert!(
|
||||
!a.run_dir.exists(),
|
||||
"custom 认领的目录不该被创建:{}",
|
||||
a.run_dir.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 正向:**verbatim** 路径(`\\?\…`)不折叠 —— 它是另一个物理目录,必须允许并存。
|
||||
///
|
||||
/// 抓得住:归一化时盲目 trim(verbatim 的 `Foo.` 会被折叠成 `Foo`,这条第二次登记会顺延,
|
||||
/// `-2` 断言红)。
|
||||
///
|
||||
/// 抓不住:别的重解析点形态;也不验 8.3 短名。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_verbatim_trailing_dot_path_is_not_folded() {
|
||||
let root = TempRoot::new("verbatim");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let base = pack_dir::instance_dir(&root.launcher(), "Bar");
|
||||
let verbatim =
|
||||
std::path::PathBuf::from(format!(r"\\?\{}", base.with_file_name("Bar.").display()));
|
||||
let mut first = request("1.20.1", AdoptTarget::Custom(verbatim.clone()));
|
||||
first.name = Some("First".to_string());
|
||||
adopt::adopt(&root.game(), &root.launcher(), &first).expect("verbatim custom 登记成功");
|
||||
|
||||
let mut second = request("1.20.1", AdoptTarget::InstanceDir);
|
||||
second.name = Some("Bar".to_string());
|
||||
let b = adopt::adopt(&root.game(), &root.launcher(), &second).expect("默认登记成功");
|
||||
assert_eq!(
|
||||
b.run_dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned()),
|
||||
Some("Bar".to_string()),
|
||||
"verbatim 的 Bar. 与普通 Bar 是两个目录,不该顺延"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! 真实磁盘夹具:一份「别的启动器铺好的」`.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)");
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! 快照工具自身的回归:它必须抓得住「凭空多出一个空目录」(复审 r1 的 R5)。
|
||||
//!
|
||||
//! 早先的快照只记文件:`scan` 或 `--dry-run` 若在外部树里建了一个空目录(例如
|
||||
//! `<game>/versions/<v>/mods`),快照完全不变,于是「零写入」这条承诺可以是假绿。
|
||||
//! 这里用**同一份** `snapshot.rs` 证明:目录节点、链接节点都进快照,读盘失败不吞。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::fixture::{
|
||||
link_guard, snapshot, snapshot_opt, try_link_dir, try_symlink_dir, Node, TempRoot,
|
||||
};
|
||||
|
||||
/// 目录 / 文件 / 链接三类节点都记,且链接**不跟随**(目标内容不会挂到链接路径下)。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - 目录节点缺失(`dir` 断言红);
|
||||
/// - 跟随链接(`link/inside.txt` 会同时出现在链接路径下,`is_none` 断言红);
|
||||
/// - 链接被当成普通目录(`Node::Link` 断言红)。
|
||||
///
|
||||
/// 抓不住:链接目标本身的内容变化(不跟随就看不见——这正是本工具要的语义)。
|
||||
#[test]
|
||||
fn snapshot_records_dirs_files_and_links_without_following() {
|
||||
let root = TempRoot::new("snap-shapes");
|
||||
let real = root.path().join("real");
|
||||
std::fs::create_dir_all(&real).expect("建真实目录");
|
||||
std::fs::write(real.join("inside.txt"), b"x").expect("写文件");
|
||||
let link = root.path().join("link");
|
||||
if !link_guard(try_symlink_dir(&real, &link), "快照链接夹具") {
|
||||
// 链接建不出来时仍要验目录/文件两类(guard 已显式记录跳过原因)。
|
||||
let snap = snapshot(root.path());
|
||||
assert!(matches!(snap.get("real"), Some(Node::Dir { .. })));
|
||||
assert!(matches!(
|
||||
snap.get("real/inside.txt"),
|
||||
Some(Node::File { .. })
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
let snap = snapshot(root.path());
|
||||
assert!(
|
||||
matches!(snap.get("."), Some(Node::Dir { .. })),
|
||||
"根自身也要记"
|
||||
);
|
||||
assert!(matches!(snap.get("real"), Some(Node::Dir { .. })));
|
||||
assert!(matches!(
|
||||
snap.get("real/inside.txt"),
|
||||
Some(Node::File { .. })
|
||||
));
|
||||
assert!(
|
||||
matches!(snap.get("link"), Some(Node::Link { .. })),
|
||||
"链接要记成 Link:{:?}",
|
||||
snap.get("link")
|
||||
);
|
||||
assert!(
|
||||
!snap.contains_key("link/inside.txt"),
|
||||
"不许跟随链接(否则根外/别处内容会被算进这棵树)"
|
||||
);
|
||||
}
|
||||
|
||||
/// **新增一个空目录**必须让快照变化(R5 的核心判据)。
|
||||
///
|
||||
/// 抓得住:只记文件的旧快照(`before == after` 会让第一条断言红)。
|
||||
/// 抓不住:目录 mtime 的精度(这条只比路径集合与节点形状;mtime 由 `assert_unchanged` 另外守)。
|
||||
#[test]
|
||||
fn snapshot_catches_a_new_empty_directory() {
|
||||
let root = TempRoot::new("snap-newdir");
|
||||
let game = root.game_root().join("versions").join("1.20.1");
|
||||
std::fs::create_dir_all(&game).expect("建版本目录");
|
||||
let before = snapshot(&root.game_root());
|
||||
|
||||
// 「登记过程凭空建了一个空目录」——正是旧快照看不见的那种改动。
|
||||
let planted = game.join("mods");
|
||||
std::fs::create_dir_all(&planted).expect("建空目录");
|
||||
let after = snapshot(&root.game_root());
|
||||
|
||||
assert_ne!(before, after, "新增空目录必须改变快照");
|
||||
assert!(
|
||||
!before.contains_key("versions/1.20.1/mods"),
|
||||
"改动前不该有这个节点"
|
||||
);
|
||||
assert!(
|
||||
matches!(after.get("versions/1.20.1/mods"), Some(Node::Dir { .. })),
|
||||
"新增的空目录要作为目录节点出现:{:?}",
|
||||
after.get("versions/1.20.1/mods")
|
||||
);
|
||||
|
||||
// 反向:删掉之后路径集合回到原样(目录 mtime 会变,所以只比键集合)。
|
||||
std::fs::remove_dir(&planted).expect("删空目录");
|
||||
let again = snapshot(&root.game_root());
|
||||
assert_eq!(
|
||||
before.keys().collect::<Vec<_>>(),
|
||||
again.keys().collect::<Vec<_>>(),
|
||||
"删掉空目录后路径集合应回到原样"
|
||||
);
|
||||
}
|
||||
|
||||
/// 目录的 mtime 变化也要能看见(条目增删会改父目录 mtime)。
|
||||
///
|
||||
/// 抓得住:目录节点只记路径不记 mtime(那样 `Node::Dir{mtime_ns}` 相同,第二条断言红)。
|
||||
/// 抓不住:某些文件系统对 mtime 的粗粒度(本机 NTFS 是 100ns 级;真粗到同一 tick 时这条会软过)。
|
||||
#[test]
|
||||
fn snapshot_notices_a_directory_mtime_change() {
|
||||
let root = TempRoot::new("snap-dirmtime");
|
||||
let dir = root.path().join("watched");
|
||||
std::fs::create_dir_all(&dir).expect("建目录");
|
||||
let before = snapshot(root.path());
|
||||
std::fs::write(dir.join("new.txt"), b"x").expect("写文件");
|
||||
let after = snapshot(root.path());
|
||||
let before_node = before.get("watched").expect("有该目录节点");
|
||||
let after_node = after.get("watched").expect("有该目录节点");
|
||||
assert_ne!(
|
||||
before_node, after_node,
|
||||
"目录里加了文件,目录节点(含 mtime)必须变"
|
||||
);
|
||||
}
|
||||
|
||||
/// 目标根本不存在 ⇒ `snapshot_opt` 给 `None`(与「空树」区分开)。
|
||||
///
|
||||
/// 抓得住:把「不存在」当成空快照(那样「dry-run 建了 .ULL」就看不出来)。
|
||||
/// 抓不住:存在但权限不足(那一支直接 panic,见模块文档)。
|
||||
#[test]
|
||||
fn a_missing_target_is_none_not_an_empty_snapshot() {
|
||||
let root = TempRoot::new("snap-missing");
|
||||
let absent: &Path = &root.path().join("nope");
|
||||
assert!(snapshot_opt(absent).is_none());
|
||||
std::fs::create_dir_all(absent).expect("建空目录");
|
||||
assert!(snapshot_opt(absent).is_some(), "空目录也算存在");
|
||||
}
|
||||
|
||||
/// 链接建不出来时 `try_link_dir` 会退回联接(Windows);这条只验它不 panic。
|
||||
#[test]
|
||||
fn link_fixtures_are_total() {
|
||||
let root = TempRoot::new("snap-link-total");
|
||||
let target = root.path().join("target");
|
||||
std::fs::create_dir_all(&target).expect("建目标");
|
||||
let link = root.path().join("link");
|
||||
let created = try_link_dir(&target, &link);
|
||||
if created {
|
||||
assert!(link.is_dir());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! M221 core 侧集成测试入口:从**别人的** `.minecraft` 只读登记版本(adopt)。
|
||||
//!
|
||||
//! 分工:
|
||||
//! - `fixture`:临时真实磁盘目录 + 一份「别的启动器铺好的」`.minecraft`;
|
||||
//! - `snapshot`:**全节点**快照(目录/文件/链接 + mtime),core 与 CLI 测试共用这一份;
|
||||
//! - `scan_cases`:`scan` / `inspect` 的判据(正常 / 坏 JSON / 缺父 / 成环 / 缺清单 / 越界符号链接);
|
||||
//! - `plan_cases`:还没登记时的**只读**判据与拒绝理由(dry-run 零写盘 / 缺父 / 不存在 / 非法目标);
|
||||
//! - `collision_cases`:缺省运行目录**不许被两个实例共用**(slug / 大小写 / 已被占 / 认领但未创建);
|
||||
//! - `register_cases`:`adopt` 的落点与写盘面(缺省独立目录 / shared / isolated / custom /
|
||||
//! 重名 / 重复 / 非空目录 / 数据区联接 / **slug 与大小写碰撞不许共用运行目录** / 外部树全节点不变);
|
||||
//! - `surface_cases`:配置写面被链接走出去必须拒绝(R2)、`versions` 入口断链必须报错(R3);
|
||||
//! - `fixture_cases`:快照工具自身的回归(它得抓得住「凭空多一个空目录」,R5)。
|
||||
//!
|
||||
//! 这些用例**不启动真实 Minecraft**:验的是「登记这一步做了什么、没做什么」,启动链路由
|
||||
//! `tests/game/*` 与 CLI 的 `launch --dry-run` 各自覆盖。
|
||||
|
||||
mod collision_cases;
|
||||
mod fixture;
|
||||
mod fixture_cases;
|
||||
mod plan_cases;
|
||||
mod register_cases;
|
||||
mod scan_cases;
|
||||
mod snapshot;
|
||||
mod surface_cases;
|
||||
@@ -0,0 +1,118 @@
|
||||
//! `plan` / `inspect` 的**只读**判据与拒绝理由:零写盘、缺父/不存在/非法目标的报错形状。
|
||||
//!
|
||||
//! 与 `register_cases` 的分工:那边验「登记写出了什么」,这边验「还没登记时算出了什么、拒绝得对不对」。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use unlucky_core::game::adopt::{self, AdoptError, AdoptTarget};
|
||||
use unlucky_core::game::install::instance::Isolation;
|
||||
|
||||
use crate::fixture::{
|
||||
assert_unchanged, foreign_minecraft, request, snapshot, snapshot_opt, TempRoot,
|
||||
};
|
||||
|
||||
/// 缺省档位本身就是「数据区独立目录」,而且**不是** `isolated`(判据的单一真相源)。
|
||||
///
|
||||
/// 抓得住:把 `#[default]` 挪到 `VersionDir`/`Shared`(第一条断言红),或让缺省档映射到
|
||||
/// `Isolated`(第二条断言红)——后者正是「导入别人的版本后第一次启动就在他的 `versions/<id>/`
|
||||
/// 里长 mods/saves」的那个坑。
|
||||
/// 抓不住:CLI 是否真的用 `AdoptTarget::default()`(那条由 cli/adopt_test 的行为断言钉着)。
|
||||
#[test]
|
||||
fn the_default_target_is_an_own_dir_not_the_global_isolated_mode() {
|
||||
let target = AdoptTarget::default();
|
||||
assert_eq!(target, AdoptTarget::InstanceDir);
|
||||
assert_eq!(target.as_str(), "instance_dir");
|
||||
assert_eq!(target.isolation(), Isolation::Custom);
|
||||
assert_ne!(target.isolation(), Isolation::Isolated);
|
||||
}
|
||||
|
||||
/// `--isolation custom --game-dir <相对路径>` ⇒ 明确报错(不猜、不悄悄改成绝对路径)。
|
||||
///
|
||||
/// 抓得住:相对路径被默默接受(`TargetInvalid` 变成成功)。
|
||||
/// 抓不住:Windows 上 `C:foo` 这种「带盘符但非绝对」的形状,只看 `is_absolute`。
|
||||
#[test]
|
||||
fn custom_target_requires_an_absolute_directory() {
|
||||
let root = TempRoot::new("custom-rel");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::Custom(PathBuf::from("relative/dir"))),
|
||||
)
|
||||
.expect_err("相对路径应被拒");
|
||||
assert_eq!(error.code(), "adopt_target_invalid");
|
||||
assert!(error.to_string().contains("绝对路径"), "{error}");
|
||||
}
|
||||
|
||||
/// `--dry-run` 的 core 形态(`plan`):一个文件都不写、目录也不建。
|
||||
///
|
||||
/// 抓得住:`plan` 顺手建了运行目录或写了档案(两种断言都会红)。
|
||||
/// 抓不住:CLI 的 `--dry-run` 是否真的只调 `plan`(那条在 cli 侧用例里)。
|
||||
#[test]
|
||||
fn plan_writes_nothing_at_all() {
|
||||
let root = TempRoot::new("plan");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let before = snapshot(root.path());
|
||||
|
||||
let plan = adopt::plan(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect("计划应成功");
|
||||
let after = snapshot(root.path());
|
||||
assert_unchanged(&before, &after, "plan 之后(含 .ULL)");
|
||||
|
||||
assert!(plan.candidate.adoptable());
|
||||
assert!(!plan.run_dir.exists(), "plan 不该建目录");
|
||||
// `.ULL` 连**空目录**都不许出现(存在性用 `snapshot_opt`,与「空树」区分开)。
|
||||
assert!(
|
||||
snapshot_opt(&root.path().join(".ULL")).is_none(),
|
||||
"plan 不该建配置目录(连空的也不行)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 不可登记的版本(缺父)⇒ 一条都不登记,问题清单原样带出。
|
||||
///
|
||||
/// 抓得住:缺父的版本被登记(启动必失败却显示「导入成功」)、问题被折叠成一句空话。
|
||||
/// 抓不住:用户在原来的启动器里修好之后重试(那条由重试路径自然覆盖)。
|
||||
#[test]
|
||||
fn not_adoptable_versions_are_refused_with_their_reasons() {
|
||||
let root = TempRoot::new("refuse");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1-orphan", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("缺父版本应被拒");
|
||||
assert_eq!(error.code(), "adopt_not_adoptable");
|
||||
let codes: Vec<&str> = error
|
||||
.problems()
|
||||
.iter()
|
||||
.map(|problem| problem.code())
|
||||
.collect();
|
||||
assert_eq!(codes, vec!["missing_parent"]);
|
||||
assert!(
|
||||
!root.path().join(".ULL/config/profiles.json").exists(),
|
||||
"拒绝时不许留档案"
|
||||
);
|
||||
}
|
||||
|
||||
/// 不存在的版本 ⇒ `adopt_version_not_found`(不是「帮你在网上下一个」)。
|
||||
///
|
||||
/// 抓得住:不存在的 id 被当成可登记(档案指向空版本)。
|
||||
/// 抓不住:`versions/` 整个不存在的情形(上一条扫描用例覆盖)。
|
||||
#[test]
|
||||
fn an_unknown_version_is_reported_as_not_found() {
|
||||
let root = TempRoot::new("unknown");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("9.9.9", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("不存在的版本应报错");
|
||||
assert!(matches!(error, AdoptError::UnknownVersion { .. }));
|
||||
assert_eq!(error.code(), "adopt_version_not_found");
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! `plan` / `adopt` 的落点与写盘面:登记只写自己的配置,外部 `.minecraft` 一个字节都不动。
|
||||
//!
|
||||
//! 每条用例都带「抓得住 / 抓不住」,并且**外部快照断言**是每条的核心:`versions/`
|
||||
//! `libraries/` `assets/` `mods/` `saves/` `config/` `launcher_profiles.json` 的路径集合、
|
||||
//! 字节数、内容哈希、mtime 全都不许变(`fixture::assert_unchanged`)。
|
||||
|
||||
use unlucky_core::game::adopt::{self, AdoptTarget};
|
||||
use unlucky_core::game::install::instance::{read_instance_config, read_profiles, Isolation};
|
||||
use unlucky_core::game::isolation::{derive_game_dir, IsolationMode};
|
||||
|
||||
use crate::fixture::{assert_unchanged, foreign_minecraft, request, snapshot, TempRoot};
|
||||
|
||||
/// 缺省(不给 `--isolation`):运行目录落 ULL 数据区的独立目录,隔离档写 `custom`。
|
||||
///
|
||||
/// 抓得住:缺省沿用全局 `isolated`(那样 `run_dir` 会变成 `versions/1.20.1`)、
|
||||
/// `gameDir` 没落盘、外部文件被写(哈希/mtime 断言)、profiles 没登记。
|
||||
/// 抓不住:真实启动是否真的把 `--gameDir` 用上(core 侧只验派生结果,CLI 侧用 `launch --dry-run` 验)。
|
||||
#[test]
|
||||
fn default_target_lands_in_the_launcher_data_area() {
|
||||
let root = TempRoot::new("default");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let before = snapshot(&root.game_root());
|
||||
|
||||
let outcome = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect("登记应成功");
|
||||
let after = snapshot(&root.game_root());
|
||||
assert_unchanged(&before, &after, "缺省登记之后");
|
||||
|
||||
assert_eq!(outcome.isolation, Isolation::Custom);
|
||||
assert!(outcome.created);
|
||||
assert!(outcome.dir_created, "缺省目录应被新建");
|
||||
let instances_dir = root.path().join(".ULL/data/instances");
|
||||
assert!(
|
||||
outcome.run_dir.starts_with(&instances_dir),
|
||||
"运行目录必须在 ULL 数据区:{}",
|
||||
outcome.run_dir.display()
|
||||
);
|
||||
assert!(outcome.run_dir.is_dir());
|
||||
assert!(
|
||||
!outcome.run_dir.starts_with(root.game_root()),
|
||||
"缺省运行目录不许落在别人的 .minecraft 里"
|
||||
);
|
||||
|
||||
// 落盘的三样东西:profiles.json、实例设置、新建的空运行目录。
|
||||
let profiles = read_profiles(&root.launcher()).expect("读档案");
|
||||
assert_eq!(profiles.profiles.len(), 1);
|
||||
assert_eq!(profiles.profiles[0].name, "1.20.1");
|
||||
assert_eq!(profiles.profiles[0].version_id, "1.20.1");
|
||||
let config = read_instance_config(&root.launcher(), &outcome.instance_id)
|
||||
.expect("读设置")
|
||||
.expect("设置文件存在");
|
||||
assert_eq!(config.isolation, Some(Isolation::Custom));
|
||||
assert_eq!(config.game_dir.as_deref(), Some(outcome.run_dir.as_path()));
|
||||
|
||||
// 启动时 `--gameDir` 真的会取这个目录(D20 覆盖链的权威求值)。
|
||||
let derivation = derive_game_dir(
|
||||
&root.game(),
|
||||
"1.20.1",
|
||||
IsolationMode::Isolated,
|
||||
Some(&config),
|
||||
None,
|
||||
)
|
||||
.expect("派生运行目录");
|
||||
assert_eq!(derivation.game_dir, outcome.run_dir);
|
||||
assert_eq!(derivation.isolation, Isolation::Custom);
|
||||
}
|
||||
|
||||
/// `--isolation shared`:运行目录 = 别人的游戏根,且**人读提示必须说出后果**。
|
||||
///
|
||||
/// 抓得住:shared 档被当成 custom 写 `game_dir`、提示里没提「共用/别人的 .minecraft」、
|
||||
/// 登记过程往游戏根写了东西(哈希断言)。
|
||||
/// 抓不住:用户是否真的读到了这句提示(那属于 UI 验收)。
|
||||
#[test]
|
||||
fn shared_target_points_at_the_game_root_and_says_what_it_means() {
|
||||
let root = TempRoot::new("shared");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let before = snapshot(&root.game_root());
|
||||
|
||||
let outcome = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::Shared),
|
||||
)
|
||||
.expect("登记应成功");
|
||||
let after = snapshot(&root.game_root());
|
||||
assert_unchanged(&before, &after, "shared 登记之后");
|
||||
|
||||
assert_eq!(outcome.isolation, Isolation::Shared);
|
||||
assert_eq!(outcome.run_dir, root.game_root());
|
||||
assert!(
|
||||
outcome.notes.iter().any(|note| note.contains("共用")),
|
||||
"必须告知「共用别人的 .minecraft」:{:?}",
|
||||
outcome.notes
|
||||
);
|
||||
let config = read_instance_config(&root.launcher(), &outcome.instance_id)
|
||||
.expect("读设置")
|
||||
.expect("设置文件存在");
|
||||
assert_eq!(config.isolation, Some(Isolation::Shared));
|
||||
assert_eq!(config.game_dir, None, "shared 档不该固化成 custom");
|
||||
}
|
||||
|
||||
/// `--isolation isolated`:运行目录 = `versions/<id>`,**登记这一刻仍不往那里写任何东西**。
|
||||
///
|
||||
/// 抓得住:`VersionDir` 被漏做(`isolation` 不是 `Isolated`)、提示里没提「会新增玩家数据」、
|
||||
/// 登记当场就在版本目录里建了 `mods/`(下面的目录内容断言会红)。
|
||||
/// 抓不住:首次启动之后游戏自己往里写(那是启动链路的事,本 mission 不跑真游戏)。
|
||||
#[test]
|
||||
fn version_dir_target_is_explicit_and_writes_nothing_yet() {
|
||||
let root = TempRoot::new("isolated");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let before = snapshot(&root.game_root());
|
||||
|
||||
let outcome = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::VersionDir),
|
||||
)
|
||||
.expect("登记应成功");
|
||||
let after = snapshot(&root.game_root());
|
||||
assert_unchanged(&before, &after, "isolated 登记之后");
|
||||
|
||||
assert_eq!(outcome.isolation, Isolation::Isolated);
|
||||
assert_eq!(outcome.run_dir, root.game_root().join("versions/1.20.1"));
|
||||
assert!(
|
||||
outcome.notes.iter().any(|note| note.contains("新增")),
|
||||
"必须告知「第一次启动会在这里新增玩家数据」:{:?}",
|
||||
outcome.notes
|
||||
);
|
||||
assert!(!outcome.dir_created, "版本目录已存在,不该新建");
|
||||
let mut entries: Vec<String> = std::fs::read_dir(&outcome.run_dir)
|
||||
.expect("读版本目录")
|
||||
.filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
|
||||
.collect();
|
||||
entries.sort();
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec!["1.20.1.jar".to_string(), "1.20.1.json".to_string()],
|
||||
"版本目录里不该多出任何东西"
|
||||
);
|
||||
}
|
||||
|
||||
/// 展示名已被别的实例占用 ⇒ 显式报错,不改任何档案(不静默复用别的版本)。
|
||||
///
|
||||
/// 抓得住:`register` 的幂等复用路径被当成「导入成功」(那样第二个版本会指向第一个实例的档案)。
|
||||
/// 抓不住:CLI 的 hint 文案(那条在 cli 侧用例里)。
|
||||
#[test]
|
||||
fn a_taken_name_is_an_explicit_error() {
|
||||
let root = TempRoot::new("dupname");
|
||||
foreign_minecraft(&root.game_root());
|
||||
adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect("第一个登记成功");
|
||||
let profiles_before =
|
||||
std::fs::read(root.path().join(".ULL/config/profiles.json")).expect("读档案");
|
||||
|
||||
let mut second = request("1.20.1-fabric-0.15.0", AdoptTarget::InstanceDir);
|
||||
second.name = Some("1.20.1".to_string());
|
||||
let error = adopt::adopt(&root.game(), &root.launcher(), &second).expect_err("重名应被拒");
|
||||
assert_eq!(error.code(), "adopt_duplicate_name");
|
||||
let profiles_after =
|
||||
std::fs::read(root.path().join(".ULL/config/profiles.json")).expect("读档案");
|
||||
assert_eq!(profiles_before, profiles_after, "失败不许改档案");
|
||||
}
|
||||
|
||||
/// 同一个版本重复登记 ⇒ 无副作用失败(`adopt_already_registered`),档案逐字节不变。
|
||||
///
|
||||
/// 抓得住:重复登记变成「静默成功」(第二条断言会红),或失败时仍改写了档案。
|
||||
/// 抓不住:并发两次 adopt 的竞态(没有锁;本命令不是并发入口,如实记在这里)。
|
||||
#[test]
|
||||
fn repeating_the_same_adopt_fails_without_side_effects() {
|
||||
let root = TempRoot::new("repeat");
|
||||
foreign_minecraft(&root.game_root());
|
||||
adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect("第一次登记成功");
|
||||
let config_dir = root.path().join(".ULL/config");
|
||||
let before = snapshot(&config_dir);
|
||||
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("重复登记应报错");
|
||||
assert_eq!(error.code(), "adopt_already_registered");
|
||||
let after = snapshot(&config_dir);
|
||||
assert_unchanged(&before, &after, "重复登记失败之后");
|
||||
}
|
||||
|
||||
/// 缺省运行目录已存在且非空 ⇒ 拒绝(不猜「合并进已有实例」,不覆盖用户内容)。
|
||||
///
|
||||
/// 抓得住:非空目录被复用(内容被游戏写进去只是时间问题)、拒绝时仍写了档案。
|
||||
/// 抓不住:空目录复用那条正向路径(见下一条用例)。
|
||||
#[test]
|
||||
fn a_non_empty_default_run_dir_is_refused() {
|
||||
let root = TempRoot::new("occupied");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let plan = adopt::plan(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect("先算一次落点");
|
||||
std::fs::create_dir_all(&plan.run_dir).expect("建目录");
|
||||
std::fs::write(plan.run_dir.join("someone-elses-file.txt"), b"keep me").expect("写文件");
|
||||
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("非空目录应被拒");
|
||||
assert_eq!(error.code(), "adopt_target_not_empty");
|
||||
assert_eq!(
|
||||
std::fs::read(plan.run_dir.join("someone-elses-file.txt")).expect("读文件"),
|
||||
b"keep me"
|
||||
);
|
||||
assert!(
|
||||
!root.path().join(".ULL/config/profiles.json").exists(),
|
||||
"拒绝时不许留档案"
|
||||
);
|
||||
}
|
||||
|
||||
/// 缺省运行目录存在但**为空** ⇒ 复用(上次失败的残留不该让用户永远重试不了)。
|
||||
///
|
||||
/// 抓得住:空目录也被拒(那就把「先建目录再登记」这条路堵死了)。
|
||||
/// 抓不住:目录里的隐藏文件(`read_dir` 能看见,但平台差异在这里没覆盖)。
|
||||
#[test]
|
||||
fn an_empty_default_run_dir_is_reused() {
|
||||
let root = TempRoot::new("empty-dir");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let plan = adopt::plan(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect("先算一次落点");
|
||||
std::fs::create_dir_all(&plan.run_dir).expect("建空目录");
|
||||
|
||||
let outcome = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect("空目录可复用");
|
||||
assert!(!outcome.dir_created, "已存在的空目录不算新建");
|
||||
assert_eq!(outcome.run_dir, plan.run_dir);
|
||||
}
|
||||
|
||||
/// 数据区里预先放了指向外部的目录联接 ⇒ 缺省运行目录拒绝沿它写盘。
|
||||
///
|
||||
/// 抓得住:`create_dir_all` 直接沿联接在数据根之外建目录(外部会多出条目,最后一条断言红),
|
||||
/// 或错误码不是 `adopt_target_invalid`。
|
||||
/// 抓不住:数据根**自己**是链接的情形——那是用户的数据目录迁移手法,允许(判据是落点仍在
|
||||
/// 解析后的数据根内);也抓不住检查与建目录之间的 TOCTOU。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_junction_inside_the_data_area_does_not_get_written_through() {
|
||||
let root = TempRoot::new("datajunction");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let outside = root.path().join("outside-instances");
|
||||
std::fs::create_dir_all(&outside).expect("建外部目录");
|
||||
// 逐段 join:`mklink` 把正斜杠当开关,拼成一串带 `/` 的路径会被判「无效参数」。
|
||||
let data_instances = root.path().join(".ULL").join("data").join("instances");
|
||||
std::fs::create_dir_all(data_instances.parent().expect("父目录")).expect("建 data 目录");
|
||||
if !crate::fixture::link_guard(
|
||||
crate::fixture::try_junction(&outside, &data_instances),
|
||||
"数据区目录联接",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("数据区里的联接必须被拒");
|
||||
assert_eq!(error.code(), "adopt_target_invalid", "{error}");
|
||||
let entries: Vec<String> = std::fs::read_dir(&outside)
|
||||
.expect("读外部目录")
|
||||
.filter_map(|entry| Some(entry.ok()?.file_name().to_string_lossy().into_owned()))
|
||||
.collect();
|
||||
assert!(
|
||||
entries.is_empty(),
|
||||
"不许沿联接在数据根之外建目录:{entries:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! `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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! 目录树**全节点**快照:目录 / 文件 / 链接各记自己的类型与适用 mtime。
|
||||
//!
|
||||
//! # 为什么不是「只记文件」
|
||||
//!
|
||||
//! 复审 r1 的 R5:早先版本遇到目录只递归、不记录,于是「登记过程凭空建了一个空目录」
|
||||
//! (例如错误地在 `<game>/versions/<v>/mods` 里 `create_dir_all`)在快照上**完全看不出来**。
|
||||
//! 「只读外部」这条承诺必须能抓住新增/删除的**目录节点**,所以这里:
|
||||
//!
|
||||
//! - 根自身、每个目录、每个文件、每个链接都进快照(相对路径 → [`Node`]);
|
||||
//! - 目录记 mtime(条目增删会改它),文件记字节数 + mtime + 内容哈希;
|
||||
//! - **链接不跟随**:只记目标路径(跟随会把根外的内容算进「外部树」,让越界写入看起来像「没变化」);
|
||||
//! Windows 的目录联接(junction)不是 `is_symlink`,这里额外用重解析点属性识别它;
|
||||
//! - 任何读盘失败都**直接失败**(`panic`),不吞成「这项没变」——吞错就是假绿。
|
||||
//!
|
||||
//! # 为什么 CLI 测试也用这一份
|
||||
//!
|
||||
//! 以前 core 与 CLI 各写一份 walk,两份都漏了目录节点(同一处缺口两遍)。现在 CLI 的
|
||||
//! `adopt_test/fixture.rs` 用 `#[path = "../../../core/tests/adopt/snapshot.rs"]` 复用本文件:
|
||||
//! **判据只有一份**,不会再次漂移。
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
/// 快照里的一个节点。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Node {
|
||||
/// 目录(只记 mtime:条目增删会改它)。
|
||||
Dir {
|
||||
/// mtime(纳秒;取不到记 0)。
|
||||
mtime_ns: u128,
|
||||
},
|
||||
/// 文件(字节数 + mtime + 内容哈希)。
|
||||
File {
|
||||
/// 字节数。
|
||||
len: u64,
|
||||
/// mtime(纳秒)。
|
||||
mtime_ns: u128,
|
||||
/// 内容哈希(稳定即可,不要密码学强度)。
|
||||
hash: u64,
|
||||
},
|
||||
/// 符号链接 / Windows 目录联接:只记目标,**不跟随**。
|
||||
Link {
|
||||
/// 链接目标(原样,可能是相对路径;读不出来时记 `<unreadable-link>`)。
|
||||
target: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
/// 一次快照:相对路径(根自身是 `.`)→ 节点。
|
||||
pub type Snapshot = BTreeMap<String, Node>;
|
||||
|
||||
/// 拍一次全节点快照(目标不存在 = 空快照,见 [`snapshot_opt`] 的区分)。
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// 任何目录/元数据/文件读不出来都 panic:读不出来不能当成「没变化」。
|
||||
pub fn snapshot(root: &Path) -> Snapshot {
|
||||
snapshot_opt(root).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 同 [`snapshot`],但目标**不存在**时返回 `None`(用于区分「空树」与「根本没有」)。
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// 同上(存在但读不出来即 panic)。
|
||||
pub fn snapshot_opt(root: &Path) -> Option<Snapshot> {
|
||||
match std::fs::symlink_metadata(root) {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None,
|
||||
Err(error) => panic!("快照根读不出来 {}:{error}", root.display()),
|
||||
}
|
||||
let mut out = Snapshot::new();
|
||||
out.insert(".".to_string(), node_of(root));
|
||||
walk(root, root, &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn walk(root: &Path, dir: &Path, out: &mut Snapshot) {
|
||||
let entries = std::fs::read_dir(dir)
|
||||
.unwrap_or_else(|error| panic!("目录应可读 {}:{error}", dir.display()));
|
||||
for entry in entries {
|
||||
let entry = entry.unwrap_or_else(|error| panic!("目录项应可读 {}:{error}", dir.display()));
|
||||
let path = entry.path();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let node = node_of(&path);
|
||||
let is_dir = matches!(node, Node::Dir { .. });
|
||||
out.insert(relative, node);
|
||||
if is_dir {
|
||||
walk(root, &path, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个节点的形状(链接/联接**不跟随**)。
|
||||
fn node_of(path: &Path) -> Node {
|
||||
let meta = std::fs::symlink_metadata(path)
|
||||
.unwrap_or_else(|error| panic!("元数据应可读 {}:{error}", path.display()));
|
||||
if link_like(&meta) {
|
||||
let target =
|
||||
std::fs::read_link(path).unwrap_or_else(|_| PathBuf::from("<unreadable-link>"));
|
||||
return Node::Link { target };
|
||||
}
|
||||
let mtime_ns = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map_or(0, |duration| duration.as_nanos());
|
||||
if meta.file_type().is_dir() {
|
||||
return Node::Dir { mtime_ns };
|
||||
}
|
||||
let bytes = std::fs::read(path)
|
||||
.unwrap_or_else(|error| panic!("文件应可读 {}:{error}", path.display()));
|
||||
Node::File {
|
||||
len: bytes.len() as u64,
|
||||
mtime_ns,
|
||||
hash: hash(&bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// 符号链接(所有平台)或 Windows 重解析点(junction / mount point)。
|
||||
///
|
||||
/// `FileType::is_symlink` 对 junction 返回 `false`,所以 Windows 上另看
|
||||
/// `FILE_ATTRIBUTE_REPARSE_POINT`:不识别它就会把 junction 当普通目录**递归进去**,
|
||||
/// 等于跟随链接读根外内容。
|
||||
fn link_like(meta: &std::fs::Metadata) -> bool {
|
||||
if meta.file_type().is_symlink() {
|
||||
return true;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
|
||||
meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 内容哈希(只要稳定、不需要密码学强度)。
|
||||
fn hash(bytes: &[u8]) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
bytes.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// 断言两次快照**完全一致**:路径集合(含目录/链接)与每个节点的形状都不许变。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - 新增/删除**空目录**(目录节点进快照)、目录条目变化(目录 mtime 变);
|
||||
/// - 文件被改写(哈希变、mtime 变)或被删(路径集合变);
|
||||
/// - 链接目标被改(`Link.target` 变)。
|
||||
///
|
||||
/// 抓不住:只读打开(无可观测痕迹)、文件内容相同且 mtime 未被改写的「伪写」、
|
||||
/// 以及检查期间的 TOCTOU。
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// 快照不一致即 panic(消息里点名第一个不同的路径)。
|
||||
pub fn assert_unchanged(before: &Snapshot, after: &Snapshot, what: &str) {
|
||||
assert_eq!(
|
||||
before.keys().collect::<Vec<_>>(),
|
||||
after.keys().collect::<Vec<_>>(),
|
||||
"{what}:路径集合(含目录/链接节点)变了"
|
||||
);
|
||||
for (path, node) in before {
|
||||
assert_eq!(
|
||||
node,
|
||||
after.get(path).expect("同一路径"),
|
||||
"{what}:节点被改动 -> {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//! 写盘面与入口可用性:**配置目录被链出去**必须拒绝(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_write_surface_in_game_tree");
|
||||
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("配置写面被链走时必须拒绝登记");
|
||||
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
|
||||
|
||||
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_write_surface_in_game_tree");
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/// `.ULL/data` 被预先联接到**游戏树**上 ⇒ 默认登记必须拒绝(r2 R2.2),而不是写完再说「隔离」。
|
||||
///
|
||||
/// 复审 r2 的实跑:`data -> .minecraft` 时 dry-run 与 adopt 都 exit0,外部多出
|
||||
/// `instances/<slug>` 且游戏根 mtime 变化,输出仍称「与别人的 .minecraft 隔离」。
|
||||
///
|
||||
/// 抓得住:写面重叠不核(那样这次登记会成功,游戏树快照与「外部零新增」断言都会红);
|
||||
/// 只核配置不核 data(`data` 面上的缺省运行目录仍会落进游戏树)。
|
||||
///
|
||||
/// 抓不住:检查与写入之间的 TOCTOU;也不把「后续游戏自己写盘」算作本判据的证据。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn data_linked_into_the_game_tree_is_refused_before_any_write() {
|
||||
use crate::fixture::try_junction;
|
||||
|
||||
let root = TempRoot::new("data-to-game");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let data = root.path().join(".ULL").join("data");
|
||||
std::fs::create_dir_all(data.parent().expect(".ULL")).expect("建 .ULL");
|
||||
if !link_guard(try_junction(&root.game_root(), &data), "data -> 游戏树联接") {
|
||||
return;
|
||||
}
|
||||
let before = snapshot(&root.game_root());
|
||||
|
||||
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_write_surface_in_game_tree");
|
||||
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("默认登记必须拒绝与游戏树重叠的 data 写面");
|
||||
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
|
||||
|
||||
assert_unchanged(
|
||||
&before,
|
||||
&snapshot(&root.game_root()),
|
||||
"拒绝之后(游戏树零写入)",
|
||||
);
|
||||
assert!(
|
||||
!root.game_root().join("instances").exists(),
|
||||
"绝不许在别人的 .minecraft 里建 instances 目录"
|
||||
);
|
||||
}
|
||||
|
||||
/// `.ULL` 整棵树被预先联接到游戏树 ⇒ 同样拒绝(配置 + data 一起落进游戏树)。
|
||||
///
|
||||
/// 抓得住:只核 `data` 或只核 `config/instances`(那样 `profiles.json` 与 `data/` 仍会写穿)。
|
||||
///
|
||||
/// 抓不住:`<.ULL>` 自己是链接但指向**游戏树之外**的情形——那是合法迁移,见下一条正向对照。
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn home_linked_into_the_game_tree_is_refused_before_any_write() {
|
||||
use crate::fixture::try_junction;
|
||||
|
||||
let root = TempRoot::new("home-to-game");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let home = root.path().join(".ULL");
|
||||
if !link_guard(try_junction(&root.game_root(), &home), ".ULL -> 游戏树联接") {
|
||||
return;
|
||||
}
|
||||
let before = snapshot(&root.game_root());
|
||||
|
||||
let error = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect_err("默认登记必须拒绝与游戏树重叠的 .ULL 写面");
|
||||
assert_eq!(error.code(), "adopt_write_surface_in_game_tree");
|
||||
|
||||
assert_unchanged(
|
||||
&before,
|
||||
&snapshot(&root.game_root()),
|
||||
"拒绝之后(游戏树零写入)",
|
||||
);
|
||||
}
|
||||
|
||||
/// **正向对照**:把 `data` 整体迁到游戏树**之外**仍然允许(迁移是合法的,重叠才拒)。
|
||||
///
|
||||
/// 抓得住:把「与游戏树重叠」写成「任何链接都拒」(这条正向用例会红)。
|
||||
/// 抓不住:迁移后的数据目录内容(只断言登记成功且游戏树不变)。
|
||||
#[test]
|
||||
fn data_migrated_outside_the_game_tree_still_registers() {
|
||||
let root = TempRoot::new("data-outside");
|
||||
foreign_minecraft(&root.game_root());
|
||||
let external = root.path().join("external-data");
|
||||
std::fs::create_dir_all(&external).expect("建外部数据目录");
|
||||
let data = root.path().join(".ULL").join("data");
|
||||
std::fs::create_dir_all(data.parent().expect(".ULL")).expect("建 .ULL");
|
||||
if !link_guard(try_link_dir(&external, &data), "data -> 游戏树之外的联接") {
|
||||
return;
|
||||
}
|
||||
let before = snapshot(&root.game_root());
|
||||
|
||||
let outcome = adopt::adopt(
|
||||
&root.game(),
|
||||
&root.launcher(),
|
||||
&request("1.20.1", AdoptTarget::InstanceDir),
|
||||
)
|
||||
.expect("迁到游戏树之外的 data 应允许");
|
||||
// 对外返回的是**逻辑**路径(`<.ULL>/data/instances/<slug>`),真实位置才是迁移后的目录。
|
||||
let real = outcome.run_dir.canonicalize().expect("运行目录真实路径");
|
||||
let external_real = external.canonicalize().expect("外部数据目录真实路径");
|
||||
assert!(
|
||||
real.starts_with(&external_real),
|
||||
"运行目录的真实位置应落在迁移后的数据目录里:{}",
|
||||
real.display()
|
||||
);
|
||||
assert_unchanged(
|
||||
&before,
|
||||
&snapshot(&root.game_root()),
|
||||
"登记之后(游戏树零写入)",
|
||||
);
|
||||
}
|
||||
|
||||
/// **更高祖先断链**(r2 R2.3):完整版本树挂在迁移联接下面,联接目标被搬走后 scan 不许报空。
|
||||
///
|
||||
/// 复审 r2 的对照:`--data-dir link/nested` 断开前 total=1;把 `link` 的目标改名后,
|
||||
/// `versions` 与游戏根本身都 NotFound,旧实现返回 `ok:true / total:0`。
|
||||
///
|
||||
/// 抓得住:只在 `versions` 与游戏根两层 lstat(更高层断链会漏,第二条断言红);
|
||||
/// 或反过来把「真正的新数据根」也判成错误(断开**之前**那条 total==1 的断言与
|
||||
/// `a_truly_missing_versions_dir_stays_empty` 会红)。
|
||||
#[test]
|
||||
fn a_dangling_ancestor_above_the_game_root_is_not_an_empty_scan() {
|
||||
let root = TempRoot::new("ancestor-dangling");
|
||||
let real = root.path().join("target");
|
||||
let game = real.join("nested").join(".minecraft");
|
||||
std::fs::create_dir_all(game.join("versions").join("1.20.1")).expect("建完整版本树");
|
||||
std::fs::write(
|
||||
game.join("versions/1.20.1/1.20.1.json"),
|
||||
br#"{"id":"1.20.1","type":"release","mainClass":"x","libraries":[]}"#,
|
||||
)
|
||||
.expect("写清单");
|
||||
let link = root.path().join("game-parent-link");
|
||||
if !link_guard(try_link_dir(&real, &link), "游戏根上层联接") {
|
||||
return;
|
||||
}
|
||||
let data_root = root.path().join("data");
|
||||
std::fs::create_dir_all(&data_root).expect("建数据根");
|
||||
let minecraft = link.join("nested").join(".minecraft");
|
||||
let game_layout = unlucky_core::game::GameLayout::new(&minecraft);
|
||||
let launcher = unlucky_core::platform::layout::LauncherLayout::new(data_root.join(".ULL"));
|
||||
|
||||
// 断开之前:完整版本树看得见。
|
||||
let scan = adopt::scan(&game_layout, &launcher).expect("联接完好的树应可扫");
|
||||
assert_eq!(scan.candidates.len(), 1, "完整版本树应扫到 1 个候选");
|
||||
|
||||
// 断开:把联接目标改名,入口还在但解析不出来(versions 与游戏根本身都 NotFound)。
|
||||
std::fs::rename(&real, root.path().join("moved-target")).expect("移走联接目标");
|
||||
assert!(
|
||||
std::fs::symlink_metadata(&link).is_ok(),
|
||||
"夹具有问题:断链入口应仍能被 lstat 看到"
|
||||
);
|
||||
|
||||
let error =
|
||||
adopt::scan(&game_layout, &launcher).expect_err("祖先断链必须具名报错,不能当空结果");
|
||||
assert_eq!(error.code(), "adopt_versions_unreadable", "{error}");
|
||||
}
|
||||
Reference in new issue
Block a user