复审 p2-5items 的五项,全部按「先能红的用例,再改实现」: R1(默认目录可被不同实例隐式共用,产品缺陷):新增 `adopt/run_dir.rs`——登记前读**每个已有实例** 的 `gameDir`(现役设置 + M27 前档案的遗留字段),用「真实路径 + 拼回不存在的尾段 + 大小写归一」 (Windows/macOS)比较;撞上就顺延 `<base>-2/-3…`(上限 32,撞满 `adopt_no_free_run_dir`)并在 `notes` 点名撞的是哪个实例;**非空且无人认领**仍拒绝(那是用户数据,不偷偷换目录)。这样 `Better MC` 与 `Better-MC`(slug 同名)、Windows 上 `Foo` 与 `foo`(同目录)都不会共用一个运行目录。 大小写归一在 macOS 上是**保守**选择:大小写敏感卷上会多一次顺延,换来的是「绝不共用」在任何卷上成立。 R2(配置写面 junction 写穿到外部游戏根,产品缺陷):`run_dir::ensure_config_write_surface()` 在 `plan`(dry-run 也报)与 `adopt`(写盘前)各核一次——`config/`、`config/instances/`、 `profiles.json` 的真实落点必须在 `<.ULL>` 内,且**不得经链接落进游戏根**(错误按真实归属分类: 「逃出 .ULL」vs「落进游戏根」)。`.ULL` 本身在游戏根内是布局事实,附加判据在该布局下零判别力, 主判据仍然拦下一切跑出 `.ULL` 的落点——这不是给链接开后门。新码 `adopt_config_surface_unsafe`。 R3(dangling versions 被当不存在,产品缺陷):`scan` 的 `read_dir` `NotFound` 再三分—— `lstat(versions)` 看见入口(断链符号链接/目录联接/目标被移走)或游戏根解析失败(祖先断链)⇒ `adopt_versions_unreadable`;两者都不是才算「真没有」。`inspect` 也补了同族判据(条目存在但 不是可读目录 ⇒ `directory_unreadable`,不再混成「没装」)。 R4(强制链接测试逻辑反转,假绿):`fixture::link_gate(created, required) -> Run|Skip|Fail` 是纯 真值表,`link_guard_with()` 显式传参(`link_guard()` 只读环境变量);新增 `link_guard_executes_all_three_branches` 用 `catch_unwind` **执行** Fail/Skip 两个分支。 四处调用点统一走 guard;建链接/联接的夹具收进 `fixture.rs`。 R5(快照遗漏目录节点,假绿):新增 `tests/adopt/snapshot.rs`——全节点快照(根/目录/文件/链接 各记类型 + 适用 mtime;目录记 mtime、链接只记目标不跟随、Windows 用重解析点属性认 junction、 读盘失败直接 panic)。**CLI 测试用 `#[path]` 复用同一份**,判据只一处;新增 `fixture_cases` 五条工具自测,`plan`/dry-run 的零写盘判据改用 `snapshot_opt`(连空目录都不许出现)。 验证(本轮持久日志,`target/m221-r1/`,git 忽略、不入库): - `cargo fmt --all --check` 0;`cargo check -p unlucky-core -p unlucky-cli --all-targets` 0; - `cargo test -p unlucky-core --test adopt` **37 passed / 0 failed**(另跑一次 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 同样 37 passed); - `cargo test -p unlucky-cli --test adopt_test` **11 passed / 0 failed**; - `cargo clippy -p unlucky-core -p unlucky-cli --all-targets -- -D warnings` 0; - 四次**实现-only 变异**(缺省不查认领 / 配置写面不核 / versions 的 NotFound 当空 / 快照不记目录) 全部以**断言红**收场(不是编译错误、不是环境错误),每次还原后与基线清单逐文件一致,收尾再跑绿。 界限:只覆盖 core+cli 的定向目标(不等于 workspace 全量);没有启动真实 Minecraft;GUI 未接; 判据是检查那一刻的快照,TOCTOU 与非 junction 重解析点仍列边界。
82 lines
3.1 KiB
Rust
82 lines
3.1 KiB
Rust
//! CLI 侧夹具:一个「别的启动器铺好了 `.minecraft`」的数据根。
|
||
//!
|
||
//! 快照工具**复用 core 测试那一份**(`crates/core/tests/adopt/snapshot.rs`,全节点:目录/文件/链接
|
||
//! 各记形状与 mtime)。以前 CLI 自己抄了一份 walk,两份都漏了目录节点——复审 r1 的 R5 正是这个洞,
|
||
//! 所以这里直接 `#[path]` 引用同一份源码:判据只有一处,不会再漂。
|
||
|
||
#[path = "../../../core/tests/adopt/snapshot.rs"]
|
||
mod snapshot;
|
||
|
||
pub use snapshot::{assert_unchanged, snapshot, snapshot_opt, Node};
|
||
|
||
use std::path::Path;
|
||
|
||
/// 造一份「别的启动器已经铺好的」数据根(`<root>/.minecraft` + `<root>/.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"}}}"#,
|
||
);
|
||
}
|
||
|
||
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": []
|
||
}"#;
|