Files
Unlucky-Launcher/crates/core/tests/adopt/collision_cases.rs
T
AskaEth 8110cde3f5 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 重解析点仍列边界。
2026-11-09 23:13:57 +08:00

145 lines
7.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 运行目录**碰撞**: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
);
}