//! CLI 侧夹具:一个「别的启动器铺好了 `.minecraft`」的数据根 + 全量快照工具。 use std::collections::hash_map::DefaultHasher; use std::collections::BTreeMap; use std::hash::{Hash, Hasher}; use std::path::Path; use std::time::UNIX_EPOCH; /// 造一份「别的启动器已经铺好的」数据根(`/.minecraft` + `/.ULL`): /// /// | 路径 | 用意 | /// | --- | --- | /// | `versions/1.20.1/` | 正常版本(清单 + jar),可登记、可 `launch --dry-run` | /// | `versions/1.20.1-orphan/` | 缺父版本 ⇒ 不可登记 | /// | `libraries/` `assets/` | 恒共享层(登记不该碰) | /// | `mods/` `saves/` | 玩家数据(登记不该碰) | /// | `launcher_profiles.json` | 别的启动器的元数据(连读都不该读,更不该写) | pub fn foreign_data_root(root: &Path) { let minecraft = root.join(".minecraft"); let versions = minecraft.join("versions"); write_version(&versions, "1.20.1", MANIFEST_OK, true); write_version(&versions, "1.20.1-orphan", MANIFEST_ORPHAN, true); write_file( &minecraft.join("libraries/com/mojang/brigadier/1.0.18/brigadier-1.0.18.jar"), b"library-bytes", ); write_file( &minecraft.join("assets/indexes/5.json"), b"{\"objects\":{}}", ); write_file(&minecraft.join("mods/jei.jar"), b"mod-bytes"); write_file(&minecraft.join("saves/World/level.dat"), b"save-bytes"); write_file( &minecraft.join("launcher_profiles.json"), br#"{"profiles":{"1.20.1":{"name":"1.20.1","lastVersionId":"1.20.1"}}}"#, ); } /// 全量快照:相对路径 → (字节数, 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 Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); let Ok(meta) = std::fs::symlink_metadata(&path) else { continue; }; if meta.is_dir() { walk(root, &path, out); continue; } let relative = path .strip_prefix(root) .unwrap_or(&path) .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 { assert_eq!( before, after.get(path).expect("同一路径"), "{what}:文件被改动 -> {path}" ); } } fn write_version(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"junk").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("写文件"); } const MANIFEST_OK: &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": 4, "url": "http://example.invalid/client.jar" } } }"#; 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": [] }"#;