CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
186 lines
6.9 KiB
Rust
186 lines
6.9 KiB
Rust
//! 数据目录迁移:计划校验、逐分区搬动、收据 + 指针、失败逆序回滚。
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use unlucky_core::platform::data_dir::{DataMode, ResolvedData};
|
|
use unlucky_core::platform::dir_migration::{self, DirMigrationOptions};
|
|
use unlucky_core::platform::error::PlatformError;
|
|
use unlucky_core::platform::layout::{LauncherLayout, Partition};
|
|
use unlucky_core::platform::receipt::{self, ReceiptKind};
|
|
use unlucky_core::platform::redirect;
|
|
|
|
use crate::common::TempDir;
|
|
|
|
fn resolved(home: &Path) -> ResolvedData {
|
|
ResolvedData {
|
|
mode: DataMode::Custom,
|
|
launcher_home: home.to_path_buf(),
|
|
game_home: home.join(".minecraft"),
|
|
redirected_from: None,
|
|
}
|
|
}
|
|
|
|
fn seed(home: &Path) {
|
|
let layout = LauncherLayout::new(home.to_path_buf());
|
|
layout.ensure().expect("建四分区");
|
|
std::fs::create_dir_all(layout.auth_data()).expect("auth 目录");
|
|
std::fs::create_dir_all(layout.sha1_cache("ab")).expect("缓存分片");
|
|
std::fs::write(layout.launcher_config(), b"{\"x\":1}").expect("写配置");
|
|
std::fs::write(layout.auth_data().join("token.json"), b"{}").expect("写凭据");
|
|
std::fs::write(layout.sha1_cache("ab").join("hash"), b"payload").expect("写缓存");
|
|
std::fs::write(layout.logs().join("launcher.log"), b"log").expect("写日志");
|
|
}
|
|
|
|
#[test]
|
|
fn plan_orders_partitions_and_skips_missing_ones() {
|
|
let temp = TempDir::new("dir-migration-plan");
|
|
seed(&temp.child("old/.ULL"));
|
|
let from = resolved(&temp.child("old/.ULL"));
|
|
let to = resolved(&temp.child("new/.ULL"));
|
|
|
|
let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划");
|
|
let partitions: Vec<Partition> = plan.steps.iter().map(|step| step.partition).collect();
|
|
assert_eq!(
|
|
partitions,
|
|
vec![
|
|
Partition::Config,
|
|
Partition::Data,
|
|
Partition::Logs,
|
|
Partition::Cache
|
|
],
|
|
"配置最先、缓存最后"
|
|
);
|
|
|
|
let cache_off = DirMigrationOptions {
|
|
migrate_cache: false,
|
|
};
|
|
let plan = dir_migration::plan(&from, &to, &cache_off).expect("计划");
|
|
assert_eq!(plan.steps.len(), 3);
|
|
assert!(plan
|
|
.steps
|
|
.iter()
|
|
.all(|step| step.partition != Partition::Cache));
|
|
}
|
|
|
|
#[test]
|
|
fn execute_moves_everything_and_leaves_a_pointer() {
|
|
let temp = TempDir::new("dir-migration-run");
|
|
let old_home = temp.child("old/.ULL");
|
|
let new_home = temp.child("new/.ULL");
|
|
seed(&old_home);
|
|
let from = resolved(&old_home);
|
|
let to = resolved(&new_home);
|
|
|
|
let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划");
|
|
let outcome = dir_migration::execute(&plan, 1_700_000_000_000).expect("执行");
|
|
|
|
assert_eq!(
|
|
outcome.moved,
|
|
vec![
|
|
Partition::Config,
|
|
Partition::Data,
|
|
Partition::Logs,
|
|
Partition::Cache
|
|
]
|
|
);
|
|
|
|
let moved = LauncherLayout::new(new_home.clone());
|
|
assert!(moved.launcher_config().is_file(), "配置要搬过去");
|
|
assert!(moved.auth_data().join("token.json").is_file());
|
|
assert!(moved.logs().join("launcher.log").is_file());
|
|
assert!(moved.sha1_cache("ab").join("hash").is_file());
|
|
assert!(!LauncherLayout::new(old_home.clone()).config().exists());
|
|
|
|
let stored = receipt::read(&outcome.receipt_path)
|
|
.expect("读收据")
|
|
.expect("有收据");
|
|
assert_eq!(stored.kind, ReceiptKind::DataDirMove);
|
|
assert_eq!(stored.source, old_home);
|
|
assert_eq!(stored.target, new_home);
|
|
assert_eq!(stored.applied_at_ms, 1_700_000_000_000);
|
|
|
|
let pointer = redirect::read(&old_home)
|
|
.expect("读指针")
|
|
.expect("旧目录要留指针");
|
|
assert_eq!(pointer.launcher_home, new_home);
|
|
assert_eq!(pointer.mode, DataMode::Custom);
|
|
assert_eq!(outcome.pointer_path, old_home.join("redirect.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn refuses_same_source_and_target() {
|
|
let temp = TempDir::new("dir-migration-same");
|
|
seed(&temp.child("only/.ULL"));
|
|
let home = resolved(&temp.child("only/.ULL"));
|
|
let err = dir_migration::plan(&home, &home, &DirMigrationOptions::default())
|
|
.expect_err("源目标相同要拒绝");
|
|
assert!(
|
|
matches!(err, PlatformError::MigrationRefused { .. }),
|
|
"{err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refuses_missing_source_and_non_empty_target() {
|
|
let temp = TempDir::new("dir-migration-refuse");
|
|
let from = resolved(&temp.child("gone/.ULL"));
|
|
let to = resolved(&temp.child("new/.ULL"));
|
|
assert!(matches!(
|
|
dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect_err("源不存在"),
|
|
PlatformError::MigrationRefused { .. }
|
|
));
|
|
|
|
seed(&temp.child("old/.ULL"));
|
|
let from = resolved(&temp.child("old/.ULL"));
|
|
std::fs::create_dir_all(to.launcher_home.clone()).expect("建目标");
|
|
std::fs::write(to.launcher_home.join("something"), b"x").expect("占位");
|
|
let err = dir_migration::plan(&from, &to, &DirMigrationOptions::default())
|
|
.expect_err("目标非空要拒绝");
|
|
assert!(err.to_string().contains("已有数据"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn rolls_back_moved_partitions_when_a_later_step_fails() {
|
|
let temp = TempDir::new("dir-migration-rollback");
|
|
let old_home = temp.child("old/.ULL");
|
|
let new_home = temp.child("new/.ULL");
|
|
seed(&old_home);
|
|
let from = resolved(&old_home);
|
|
let to = resolved(&new_home);
|
|
|
|
let plan = dir_migration::plan(&from, &to, &DirMigrationOptions::default()).expect("计划");
|
|
// 计划之后在目标侧造障碍:data 位置先占成普通文件 → 搬 data 必失败。
|
|
std::fs::create_dir_all(&new_home).expect("建目标根");
|
|
std::fs::write(new_home.join("data"), b"obstruction").expect("造障碍");
|
|
|
|
let err = dir_migration::execute(&plan, 42).expect_err("应当失败");
|
|
assert!(
|
|
matches!(err, PlatformError::MigrationRefused { .. }),
|
|
"{err}"
|
|
);
|
|
|
|
let old = LauncherLayout::new(old_home);
|
|
assert!(old.launcher_config().is_file(), "config 必须被回滚搬回来");
|
|
assert!(old.auth_data().join("token.json").is_file());
|
|
assert!(!old.redirect_path().exists(), "失败不该留指针");
|
|
assert!(!new_home.join("config").exists(), "目标侧不该残留已搬分区");
|
|
assert!(!new_home.join("data").is_dir(), "障碍文件保持原样");
|
|
}
|
|
|
|
#[test]
|
|
fn receipt_anti_replay_uses_source_target_signature() {
|
|
let temp = TempDir::new("dir-migration-receipt");
|
|
let receipt_path: PathBuf = temp.child("config/migration-receipt.json");
|
|
let receipt = receipt::MigrationReceipt::data_dir_move(
|
|
PathBuf::from("/old/.ULL"),
|
|
PathBuf::from("/new/.ULL"),
|
|
1_700_000_000_000,
|
|
);
|
|
receipt::write(&receipt_path, &receipt).expect("写收据");
|
|
let loaded = receipt::read(&receipt_path).expect("读").expect("有");
|
|
assert_eq!(
|
|
receipt::decide(Some(&loaded), &receipt.source_fingerprint),
|
|
receipt::ReplayDecision::SkipSameSource
|
|
);
|
|
}
|