//! 真实磁盘夹具:一份「别的启动器铺好的」`.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` | 别的启动器的元数据(连读都不该读,更不该写) | use std::collections::hash_map::DefaultHasher; use std::collections::BTreeMap; use std::hash::{Hash, Hasher}; 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; 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()) } /// 启动器数据布局(`/.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"}}}"#, ); } /// 一层文件的**全量**快照:相对路径 → (字节数, mtime 纳秒, 内容哈希)。 /// /// 三个维度都要:只看路径集合抓不住「内容被重写但文件名没变」,只看内容抓不住「mtime 被改写」 /// (M216 的「相同内容一个字节都不写」正是用 mtime 判的)。 pub fn snapshot(dir: &Path) -> BTreeMap { let mut out = BTreeMap::new(); walk(dir, dir, &mut out); out } fn walk(root: &Path, dir: &Path, out: &mut BTreeMap) { let entries = std::fs::read_dir(dir).expect("目录应可读"); for entry in entries { let entry = entry.expect("目录项应可读"); let path = entry.path(); let meta = std::fs::symlink_metadata(&path).expect("元数据应可读"); if meta.is_dir() { walk(root, &path, out); continue; } let relative = path .strip_prefix(root) .expect("相对路径") .to_string_lossy() .into_owned(); let bytes = std::fs::read(&path).expect("文件应可读"); let mtime = meta .modified() .ok() .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map_or(0, |duration| duration.as_nanos()); out.insert(relative, (bytes.len() as u64, mtime, hash(&bytes))); } } /// 内容哈希(只要稳定、不需要密码学强度)。 fn hash(bytes: &[u8]) -> u64 { let mut hasher = DefaultHasher::new(); bytes.hash(&mut hasher); hasher.finish() } /// 断言两次快照**完全一致**:路径集合、每个文件的字节数、内容哈希、mtime 全都不许变。 /// /// 抓得住:登记过程中写了/删了/改了任何外部文件(含「内容相同但重写了一遍」——mtime 会变)。 /// 抓不住:只读打开(不产生可观测痕迹)以及目录顺序之类查不到的东西。 pub fn assert_unchanged( before: &BTreeMap, after: &BTreeMap, what: &str, ) { assert_eq!( before.keys().collect::>(), after.keys().collect::>(), "{what}:路径集合变了" ); for (path, before) in before { let after = after.get(path).expect("同一路径"); assert_eq!(before, after, "{what}:文件被改动 -> {path}"); } } 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": [] }"#; /// 用 `mklink /J` 建目录联接(junction 不需要管理员权限;建不出来返回 `false`)。 /// /// 只在 Windows 上有意义:junction 不是 `is_symlink`,是把别人版本目录挂进 `versions/` 最常见的形态, /// 所以越界判据必须能看见它——这条夹具就是用来证明这点的。 #[cfg(windows)] pub fn try_junction(target: &Path, link: &Path) -> bool { // `cmd /c mklink /J `:三个参数分开传,别拼成一整条(拼成一串会被 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 } } } /// 一次登记请求(测试用:固定 `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()), } }