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
@@ -19,16 +19,18 @@
|
||||
//! | `libraries/` `assets/` | 恒共享层(登记不该碰) |
|
||||
//! | `mods/` `saves/` `config/` | 玩家数据(登记不该碰) |
|
||||
//! | `launcher_profiles.json` | 别的启动器的元数据(连读都不该读,更不该写) |
|
||||
//!
|
||||
//! 快照工具在 `snapshot.rs`(**全节点**:目录/文件/链接各记形状与 mtime,读盘失败即失败),
|
||||
//! 本模块只做 `pub use`,测试统一从 `crate::fixture::{snapshot, assert_unchanged, …}` 取。
|
||||
|
||||
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;
|
||||
|
||||
pub use super::snapshot::{assert_unchanged, snapshot, snapshot_opt, Node};
|
||||
use unlucky_core::platform::layout::LauncherLayout;
|
||||
|
||||
/// 唯一临时目录守卫(`drop` 时递归删除)。
|
||||
@@ -119,68 +121,6 @@ pub fn foreign_minecraft(root: &Path) {
|
||||
);
|
||||
}
|
||||
|
||||
/// 一层文件的**全量**快照:相对路径 → (字节数, mtime 纳秒, 内容哈希)。
|
||||
///
|
||||
/// 三个维度都要:只看路径集合抓不住「内容被重写但文件名没变」,只看内容抓不住「mtime 被改写」
|
||||
/// (M216 的「相同内容一个字节都不写」正是用 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 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<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 {
|
||||
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("建版本目录");
|
||||
@@ -249,13 +189,33 @@ const MANIFEST_CYCLE_B: &str = r#"{
|
||||
"libraries": []
|
||||
}"#;
|
||||
|
||||
/// 一次登记请求(测试用:固定 `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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 建目录符号链接(`true` = 建成)。Windows 上未开开发者模式/非管理员时可能失败。
|
||||
pub fn try_symlink_dir(target: &Path, link: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
let created = std::os::unix::fs::symlink(target, link).is_ok();
|
||||
#[cfg(windows)]
|
||||
let created = std::os::windows::fs::symlink_dir(target, link).is_ok();
|
||||
created
|
||||
}
|
||||
|
||||
/// 用 `mklink /J` 建目录联接(junction 不需要管理员权限;建不出来返回 `false`)。
|
||||
///
|
||||
/// 只在 Windows 上有意义:junction 不是 `is_symlink`,是把别人版本目录挂进 `versions/` 最常见的形态,
|
||||
/// 所以越界判据必须能看见它——这条夹具就是用来证明这点的。
|
||||
/// 只在 Windows 上有意义:junction 不是 `is_symlink`,是把别人版本目录挂进 `versions/` 最常见的
|
||||
/// 形态,所以越界判据必须能看见它——这条夹具就是用来证明这点的。
|
||||
#[cfg(windows)]
|
||||
pub fn try_junction(target: &Path, link: &Path) -> bool {
|
||||
// `cmd /c mklink /J <link> <target>`:三个参数分开传,别拼成一整条(拼成一串会被 cmd 当语法错)。
|
||||
// `cmd /c mklink /J <link> <target>`:三个参数分开传,别拼成一整条(拼成一串会被 cmd 当语法错),
|
||||
// 路径也必须**逐段 join**(`cmd` 把正斜杠当开关)。
|
||||
let output = std::process::Command::new("cmd")
|
||||
.args(["/c", "mklink", "/J"])
|
||||
.arg(link)
|
||||
@@ -281,12 +241,109 @@ pub fn try_junction(target: &Path, link: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次登记请求(测试用:固定 `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()),
|
||||
/// 建目录链接:Windows 先试符号链接、再试联接;其它平台只有符号链接。
|
||||
pub fn try_link_dir(target: &Path, link: &Path) -> bool {
|
||||
if try_symlink_dir(target, link) {
|
||||
return true;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
try_junction(target, link)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 链接夹具建不出来时怎么走(**真值表**,复审 r1 的 R4 抓的就是这张表被写反)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LinkGate {
|
||||
/// 建成了:照常跑断言。
|
||||
Run,
|
||||
/// 没建成且**没要求强制**:打印一行记录后跳过(不静默)。
|
||||
Skip,
|
||||
/// 没建成且 `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1`:**必红**,不许空跑。
|
||||
Fail,
|
||||
}
|
||||
|
||||
/// 纯判据:`(建成了吗, 是否强制)` → 怎么走。
|
||||
#[must_use]
|
||||
pub fn link_gate(created: bool, required: bool) -> LinkGate {
|
||||
match (created, required) {
|
||||
(true, _) => LinkGate::Run,
|
||||
(false, true) => LinkGate::Fail,
|
||||
(false, false) => LinkGate::Skip,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ULL_ADOPT_TEST_SYMLINK_REQUIRE=1` 时链接夹具**不许**空跑。
|
||||
#[must_use]
|
||||
pub fn links_required() -> bool {
|
||||
std::env::var("ULL_ADOPT_TEST_SYMLINK_REQUIRE")
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
== Ok("1")
|
||||
}
|
||||
|
||||
/// 链接夹具的守卫:返回 `true` = 继续跑断言;`false` = 已显式跳过(调用方 `return`)。
|
||||
///
|
||||
/// `required` 由环境变量决定([`links_required`]);判据本身是 [`link_gate`]。
|
||||
pub fn link_guard(created: bool, what: &str) -> bool {
|
||||
link_guard_with(created, links_required(), what)
|
||||
}
|
||||
|
||||
/// 同 [`link_guard`],但 `required` 显式传入(测试要能**执行**强制分支,而不靠改进程环境)。
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// `required` 且没建成链接/联接:**红**(这是复审 r1 的 R4:旧写法把方向写反了,
|
||||
/// 强制模式下建不出来反而直接「成功」返回)。
|
||||
pub fn link_guard_with(created: bool, required: bool, what: &str) -> bool {
|
||||
match link_gate(created, required) {
|
||||
LinkGate::Run => true,
|
||||
LinkGate::Skip => {
|
||||
eprintln!(
|
||||
"[link-guard] {what}:本机建不出符号链接/目录联接,已显式跳过这条用例; 设 ULL_ADOPT_TEST_SYMLINK_REQUIRE=1 可让它必跑(建不出来即红)"
|
||||
);
|
||||
false
|
||||
}
|
||||
LinkGate::Fail => panic!(
|
||||
"[link-guard] {what}:ULL_ADOPT_TEST_SYMLINK_REQUIRE=1,但本机建不出符号链接/目录联接 ——这条用例不许空跑,判红"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// 真值表的回归:**强制模式建不出来必须致命**(R4 的反转就是这张表写错)。
|
||||
///
|
||||
/// 抓得住:把 `(false, true)` 判成 `Skip`(旧实现的写法)、或把 `(false, false)` 判成 `Fail`。
|
||||
/// 抓不住:真实建链接是否成功(那由各链接用例自己走 [`link_guard`])。
|
||||
#[test]
|
||||
fn link_gate_truth_table() {
|
||||
assert_eq!(link_gate(true, false), LinkGate::Run);
|
||||
assert_eq!(link_gate(true, true), LinkGate::Run);
|
||||
assert_eq!(link_gate(false, false), LinkGate::Skip);
|
||||
assert_eq!(
|
||||
link_gate(false, true),
|
||||
LinkGate::Fail,
|
||||
"强制模式下建不出链接必须红"
|
||||
);
|
||||
}
|
||||
|
||||
/// **执行**守卫的三个分支(不只测纯判据):Run 继续、Skip 返回 false、Fail panic。
|
||||
///
|
||||
/// 抓得住:把 `Fail` 分支写成 `return false`(`catch_unwind` 捕不到 panic,最后一条断言红);
|
||||
/// 把 `Skip` 写成 panic(第一条 `catch_unwind` 不再 ok)。
|
||||
/// 抓不住:真实建链接是否成功(各链接用例自己走 [`link_guard`]);也不依赖进程级环境变量,
|
||||
/// 因此可与并行用例共存。
|
||||
#[test]
|
||||
fn link_guard_executes_all_three_branches() {
|
||||
assert!(link_guard_with(true, false, "已建成"));
|
||||
assert!(
|
||||
std::panic::catch_unwind(|| link_guard_with(false, false, "非强制模式 · 建不出")).is_ok(),
|
||||
"非强制模式不许 panic"
|
||||
);
|
||||
assert!(!link_guard_with(false, false, "非强制模式 · 建不出"));
|
||||
let forced = std::panic::catch_unwind(|| link_guard_with(false, true, "强制模式 · 建不出"));
|
||||
assert!(forced.is_err(), "强制模式下建不出来必须 panic(R4)");
|
||||
}
|
||||
Reference in new issue
Block a user