fix(game): 复审 r1 五项——运行目录不许被两实例共用 + 配置写面越界 + versions 断链 + 链接守卫反转 + 快照漏目录
复审 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 重解析点仍列边界。
This commit is contained in:
1 parent
9f5c001387
commit
8110cde3f5
19 files changed
+1363
-272
No files matched your search
@@ -120,9 +120,12 @@ pub fn topic(name: &str) -> Option<&'static str> {
|
||||
拒绝(退出码 1,原因逐条进 error.detail.problems):
|
||||
· 清单坏了 / 读不出来 / 缺 id / id 与目录名不一致
|
||||
· 缺父版本 / 父链成环(说清缺哪个、环是什么)
|
||||
· 条目解析到游戏根之外(符号链接/目录联接/祖先链接)
|
||||
· 条目解析到游戏根之外(符号链接/目录联接/祖先链接);versions 入口存在但读不出来(断链)
|
||||
· 配置写面被链到 .ULL 之外或游戏根里(config/、config/instances/、profiles.json 的真实落点)
|
||||
· 展示名已被别的实例占用;这个版本已经同名登记过(重复执行 = 无副作用失败)
|
||||
· 缺省运行目录已存在且非空(空目录可复用;不猜「合并进已有实例」)
|
||||
· 缺省目录会撞上别的实例(slug 同名 / 大小写不敏感)⇒ 自动顺延 -2/-3… 并点出撞的是谁;
|
||||
撞满 32 个后缀才报错(不会让两个实例共用一个运行目录)
|
||||
--dry-run:只检查并打印将要用的隔离档/运行目录,不写任何文件、不建目录。
|
||||
--json 输出 {\"data\":{\"dry_run\":…,\"instance\":{\"id\",\"name\",\"isolation\",\"run_dir\",
|
||||
\"run_dir_source\",\"config_path\",\"created\"},\"notes\":[…]}}。
|
||||
|
||||
@@ -60,6 +60,15 @@ pub(super) fn map_error(error: AdoptError) -> CliError {
|
||||
Some("换一个名字(缺省目录按名字派生),或用 --isolation custom --game-dir <空目录>")
|
||||
}
|
||||
AdoptError::UnknownVersion { .. } => Some("先看有哪些可用:%prog% instances scan"),
|
||||
AdoptError::NoFreeRunDir { .. } => Some(
|
||||
"换一个 --name(缺省目录按名字派生),或用 --isolation custom --game-dir <空目录>",
|
||||
),
|
||||
AdoptError::ConfigSurfaceUnsafe { .. } => Some(
|
||||
"数据根里的 config/ 或 config/instances/ 被链到别处了:先把它恢复成真实目录再登记 (整体迁移请把整个 .ULL 目录一起搬走,别只链其中一层)",
|
||||
),
|
||||
AdoptError::VersionsDirUnreadable { .. } => Some(
|
||||
"游戏根/versions 入口存在但读不出来:检查它是不是断链的符号链接/目录联接, 或把它指回真实目录后再扫",
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let cli = match hint {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
#[path = "adopt_test/cli_surface.rs"]
|
||||
mod cli_surface;
|
||||
#[path = "adopt_test/collision_cases.rs"]
|
||||
mod collision_cases;
|
||||
#[path = "adopt_test/fixture.rs"]
|
||||
mod fixture;
|
||||
#[path = "support/mod.rs"]
|
||||
@@ -15,7 +17,7 @@ use std::path::Path;
|
||||
|
||||
use support::{parse_json, run_cli, TempDir};
|
||||
|
||||
use crate::fixture::{assert_unchanged, foreign_data_root, snapshot};
|
||||
use crate::fixture::{assert_unchanged, foreign_data_root, snapshot, snapshot_opt, Node};
|
||||
|
||||
/// 缺省登记:落 ULL 数据区的独立目录,隔离档 `custom`,**不沿用全局 `isolated`**。
|
||||
///
|
||||
@@ -186,6 +188,16 @@ fn dry_run_writes_nothing() {
|
||||
let root = dir.path();
|
||||
foreign_data_root(root);
|
||||
let before = snapshot(root);
|
||||
// 判据强度自检(R5):快照必须含**目录**节点,否则「凭空建个空目录」看不出来。
|
||||
assert!(
|
||||
matches!(before.get(".minecraft"), Some(Node::Dir { .. })),
|
||||
"快照缺少目录节点:{:?}",
|
||||
before.get(".minecraft")
|
||||
);
|
||||
assert!(
|
||||
before.contains_key(".minecraft/versions"),
|
||||
"快照缺少 versions 目录节点"
|
||||
);
|
||||
|
||||
let (code, stdout, stderr) = run_cli(&[
|
||||
"instances",
|
||||
@@ -198,6 +210,11 @@ fn dry_run_writes_nothing() {
|
||||
assert_eq!(code, 0, "stdout={stdout}\nstderr={stderr}");
|
||||
let after = snapshot(root);
|
||||
assert_unchanged(&before, &after, "--dry-run 之后");
|
||||
// `--dry-run` 连 `.ULL` 这个**空目录**都不许建(存在性判据用 `snapshot_opt`)。
|
||||
assert!(
|
||||
snapshot_opt(&root.join(".ULL")).is_none(),
|
||||
"--dry-run 不该建配置目录"
|
||||
);
|
||||
assert!(stdout.contains("检查通过"), "{stdout}");
|
||||
assert!(stdout.contains("没有写任何文件"), "{stdout}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! 运行目录**碰撞**的 CLI 面:slug 同名 / 大小写不敏感不许让两个实例共用一个目录(复审 r1 的 R1)。
|
||||
//!
|
||||
//! 从 `tests/adopt_test.rs` 拆出来只为守 400 行硬线;用例体未改。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::support::{parse_json, run_cli, TempDir};
|
||||
|
||||
use crate::fixture::foreign_data_root;
|
||||
|
||||
/// **slug 碰撞的两个展示名不能绑定同一个物理运行目录**(R1 的 CLI 面)。
|
||||
///
|
||||
/// 复审 r1 的实跑:`Better MC` 与 `Better-MC` 都成功、两个 UUID、却指向同一个目录,
|
||||
/// 随后 `launch --dry-run` 的 `--gameDir` 也是同一个。这条把它钉住。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - 登记侧不查已有实例占用的目录(两个 `run_dir` 相同,第一条断言红);
|
||||
/// - `launch` 的 `game_dir` 与登记的目录不一致(第三条断言红)。
|
||||
///
|
||||
/// 抓不住:`pack_dir` 的 slug 规则本身(既有行为,本 mission 不改)。
|
||||
#[test]
|
||||
fn colliding_slug_names_get_separate_run_dirs() {
|
||||
let dir = TempDir::new("adopt-slug");
|
||||
let root = dir.path();
|
||||
foreign_data_root(root);
|
||||
let dir_arg = dir.arg();
|
||||
|
||||
let first = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--name",
|
||||
"Better MC",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
dir_arg.as_str(),
|
||||
]);
|
||||
assert_eq!(
|
||||
first.0, 0,
|
||||
"第一次登记应成功:stdout={} stderr={}",
|
||||
first.1, first.2
|
||||
);
|
||||
let first = parse_json(&first.1);
|
||||
let second = run_cli(&[
|
||||
"instances",
|
||||
"adopt",
|
||||
"1.20.1",
|
||||
"--name",
|
||||
"Better-MC",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
dir_arg.as_str(),
|
||||
]);
|
||||
assert_eq!(
|
||||
second.0, 0,
|
||||
"第二次登记应成功:stdout={} stderr={}",
|
||||
second.1, second.2
|
||||
);
|
||||
let second = parse_json(&second.1);
|
||||
|
||||
let first_dir = first["data"]["instance"]["run_dir"]
|
||||
.as_str()
|
||||
.expect("run_dir A")
|
||||
.to_string();
|
||||
let second_dir = second["data"]["instance"]["run_dir"]
|
||||
.as_str()
|
||||
.expect("run_dir B")
|
||||
.to_string();
|
||||
assert_ne!(first_dir, second_dir, "两个实例不许共用运行目录");
|
||||
assert!(
|
||||
Path::new(&first_dir).is_dir() && Path::new(&second_dir).is_dir(),
|
||||
"两个运行目录都要真实存在"
|
||||
);
|
||||
|
||||
// 真启动计划(dry-run)也要分开:两条实例各自的 game_dir 不同。
|
||||
for (value, expect) in [(&first, &first_dir), (&second, &second_dir)] {
|
||||
let id = value["data"]["instance"]["id"]
|
||||
.as_str()
|
||||
.expect("instance id");
|
||||
let args = [
|
||||
"launch",
|
||||
id,
|
||||
"--dry-run",
|
||||
"--json",
|
||||
"--data-dir",
|
||||
dir_arg.as_str(),
|
||||
];
|
||||
let (code, stdout, stderr) = run_cli(&args);
|
||||
assert_eq!(
|
||||
code, 0,
|
||||
"stdout={stdout}
|
||||
stderr={stderr}"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_json(&stdout)["data"]["game_dir"]
|
||||
.as_str()
|
||||
.expect("game_dir"),
|
||||
expect.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
//! CLI 侧夹具:一个「别的启动器铺好了 `.minecraft`」的数据根 + 全量快照工具。
|
||||
//! 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::collections::hash_map::DefaultHasher;
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
/// 造一份「别的启动器已经铺好的」数据根(`<root>/.minecraft` + `<root>/.ULL`):
|
||||
///
|
||||
@@ -36,70 +41,6 @@ pub fn foreign_data_root(root: &Path) {
|
||||
);
|
||||
}
|
||||
|
||||
/// 全量快照:相对路径 → (字节数, mtime 纳秒, 内容哈希)。
|
||||
pub fn snapshot(dir: &Path) -> BTreeMap<String, (u64, u128, u64)> {
|
||||
let mut out = BTreeMap::new();
|
||||
walk(dir, dir, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, (u64, u128, u64)>) {
|
||||
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<String, (u64, u128, u64)>,
|
||||
after: &BTreeMap<String, (u64, u128, u64)>,
|
||||
what: &str,
|
||||
) {
|
||||
assert_eq!(
|
||||
before.keys().collect::<Vec<_>>(),
|
||||
after.keys().collect::<Vec<_>>(),
|
||||
"{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("建版本目录");
|
||||
|
||||
Reference in new issue
Block a user