主任务(逐文件覆盖/保留)
- core: 新增 import/scan.rs —— 只读预检产出「冲突清单」(目标已存在且 SHA-1 不同才进),
逐条给路径/来源/两侧体积与哈希;用户删过的、内容相同的、被叠层遮蔽的一律不打扰。
- core: 新增 import/decision.rs —— ConflictDecisions(整批档 + 逐项表,逐项优先),
缺省档恒为 KeepUser;ImportRequest.conflict_policy 改由它承载,CLI 调用点不变。
- core: phase.rs 的 settle 按 path 取决策;非冲突路径不受决策影响。
- presenter: ImportScanView / ImportConflictView / ConflictDecisionsView(全部保留 /
全部覆盖 / 逐项{default,items})+ PackCommand::Scan / PackEvent::Scan;host 新增只读
spawn_scan,且新实例路径不再提前建目录(target_of 只算路径,prepare_target 才建)。
- app-iced: 导入前冲突清单页(分页 40 条/页、三入口、计数摘要、确认/取消、两行式行布局);
预检干净时直接往下导,不打扰用户。出图见 C:/Temp/m160-shots/{pick-light,pick-dark}.png。
- i18n: 新增 18 条 en/zh 文案 + 更新「两者都留」那条口径说明。
限额口径统一
- pack/archive/limits.rs 成为唯一来源:PACK_MAX_ENTRIES/UNCOMPRESSED_BYTES/ENTRY_BYTES
(20k / 512 MiB / 64 MiB);读侧 PackLimits::default 与写侧 WriteLimits::production 同源。
- 写侧不再直接用 zip::ZIP64_BYTES_THR;新增单条目闸,于是「能导出的包一定能导入」。
不等式在 pack/tests/limits.rs 里用 const 断言钉死。
测试临时物泄漏(塔台追加项)
- addon/temp.rs:TempRoot —— Drop 即删(panic 展开同样删),Deref/AsRef 到落点路径,
under()/at() 支持"子树落点、删父根"。形状照 core::update::tests::support::TempDir。
- addon::pack 与 addon::tests 两族全部改用 TempRoot(含 fixtures::Fixture 持守卫、
multimc/fabric_instance 等返回守卫);4 GiB 稀疏文件的语义一字未改,只改"谁负责删"。
- 自证:pack/tests/mod.rs 三条用例(drop 删 / panic 也删 / 子树删父根)。
测试
- import_decisions.rs(11 例):预检清单、不打扰、逐项决策后的最终文件集、基线记包内哈希。
- roundtrip_three.rs(2 例):叠层 + 冲突夹具 import→export→import 三轮文件集逐字节一致,
以及「选保留就按设计分叉」的反向证据。
- pack/tests/limits.rs(5 例):单条/总量边界、读侧用同一批常量、写出即可读回。
证据:cargo fmt --check 干净;cargo clippy --workspace --all-targets -D warnings 干净;
cargo test --workspace --all-targets -j 4 → 2507 passed / 0 failed(51 个 test binary)。
175 lines
6.7 KiB
Rust
175 lines
6.7 KiB
Rust
//! `addon` 的**导入管线**测试(M111):三格式端到端 + 三方合并 + 必须先失败的恶意样本。
|
|
//!
|
|
//! # 夹具来源
|
|
//!
|
|
//! [`fixtures`] 不是本目录的文件,而是用 `#[path]` 直接吃 M110 的
|
|
//! `addon/pack/tests/fixtures.rs`——**同一份手造包只维护一处**。它的来源声明也在那边:
|
|
//! 三份 CF / mrpack / MultiMC 包全部手造(字段取自 analyse/05 的 HMCL 解析器形状),
|
|
//! 没有一个来自真实导出;`mods/half.jar` / `config/broken.toml` / `mods/broken.jar`
|
|
//! 是刻意造的坏文件(导入器不许崩,也不许悄悄丢)。
|
|
//!
|
|
//! 为了让 `mod` 路径上的 `use super::{temp_root, write_file, zip_dir}` 解析得到,本文件把
|
|
//! 这三个辅助函数放在与 `addon/pack/tests/mod.rs` 同名同形的位置。
|
|
//!
|
|
//! # 全部离线
|
|
//!
|
|
//! 网络一律走 [`support::StubFetcher`](内存里的假文件),没有一个用例发请求。
|
|
|
|
// `fixtures.rs` 被 `addon::pack::tests` 与本目录**同时**编译(同一份手造包只维护一处),
|
|
// 所以 clippy 的 `duplicate_mod` 在这里是刻意为之:改成两份副本才是真的错。
|
|
#[allow(clippy::duplicate_mod)]
|
|
#[path = "../pack/tests/fixtures.rs"]
|
|
mod fixtures;
|
|
mod import_curseforge;
|
|
mod import_decisions;
|
|
mod import_failures;
|
|
mod import_layering;
|
|
mod import_merge;
|
|
mod import_modrinth;
|
|
mod import_multimc;
|
|
mod import_report;
|
|
mod roundtrip_three;
|
|
mod support;
|
|
|
|
use std::fs::File;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// 一次性临时根(`Drop` 即删,panic 也删)。实现与 `pack::tests` 共用一份:
|
|
/// [`crate::addon::temp::TempRoot`](两族用例都会建临时目录,守卫只此一处)。
|
|
pub(crate) use crate::addon::temp::TempRoot;
|
|
|
|
/// 写一个文件(自动建父目录)。
|
|
pub(crate) 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("写文件");
|
|
}
|
|
|
|
/// 把一个目录打成 zip(`Stored`,方便断言字节)。
|
|
pub(crate) fn zip_dir(source: &Path, target: &Path) {
|
|
if let Some(parent) = target.parent() {
|
|
std::fs::create_dir_all(parent).expect("建 zip 目录");
|
|
}
|
|
let file = File::create(target).expect("建 zip");
|
|
let mut writer = zip::ZipWriter::new(file);
|
|
let options =
|
|
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
|
let mut pending = vec![PathBuf::new()];
|
|
while let Some(relative) = pending.pop() {
|
|
let dir = source.join(&relative);
|
|
let mut children: Vec<_> = std::fs::read_dir(&dir)
|
|
.expect("读目录")
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.expect("目录项");
|
|
children.sort_by_key(std::fs::DirEntry::file_name);
|
|
for child in children {
|
|
let relative = relative.join(child.file_name());
|
|
if child.file_type().expect("类型").is_dir() {
|
|
pending.push(relative);
|
|
continue;
|
|
}
|
|
let bytes = std::fs::read(child.path()).expect("读夹具文件");
|
|
let entry = relative.to_string_lossy().replace('\\', "/");
|
|
writer.start_file(entry, options).expect("写 zip 条目");
|
|
writer.write_all(&bytes).expect("写 zip 内容");
|
|
}
|
|
}
|
|
writer.finish().expect("收尾 zip");
|
|
}
|
|
|
|
/// 用一张显式条目表打 zip(恶意样本用)。
|
|
pub(crate) fn write_zip(target: &Path, entries: &[(&str, &[u8])]) {
|
|
if let Some(parent) = target.parent() {
|
|
std::fs::create_dir_all(parent).expect("建 zip 目录");
|
|
}
|
|
let file = File::create(target).expect("建 zip");
|
|
let mut writer = zip::ZipWriter::new(file);
|
|
let options =
|
|
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
|
for (name, bytes) in entries {
|
|
writer.start_file(*name, options).expect("写 zip 条目");
|
|
writer.write_all(bytes).expect("写 zip 内容");
|
|
}
|
|
writer.finish().expect("收尾 zip");
|
|
}
|
|
|
|
/// 打一个带**符号链接条目**的 zip(`S_IFLNK` + 目标当内容)。
|
|
pub(crate) fn write_zip_with_symlink(
|
|
target: &Path,
|
|
entries: &[(&str, &[u8])],
|
|
link_name: &str,
|
|
link_target: &str,
|
|
) {
|
|
if let Some(parent) = target.parent() {
|
|
std::fs::create_dir_all(parent).expect("建 zip 目录");
|
|
}
|
|
let file = File::create(target).expect("建 zip");
|
|
let mut writer = zip::ZipWriter::new(file);
|
|
let options =
|
|
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
|
for (name, bytes) in entries {
|
|
writer.start_file(*name, options).expect("写 zip 条目");
|
|
writer.write_all(bytes).expect("写 zip 内容");
|
|
}
|
|
writer
|
|
.add_symlink(link_name, link_target, options)
|
|
.expect("写符号链接条目");
|
|
writer.finish().expect("收尾 zip");
|
|
}
|
|
|
|
/// 递归列出目录下的文件(`/` 分隔的相对路径,排序)。
|
|
///
|
|
/// "失败后目标目录零残留"就是靠它断言的:`Failed` 之后这里必须返回空表。
|
|
pub(crate) fn list_tree(root: &Path) -> Vec<String> {
|
|
fn walk(base: &Path, dir: &Path, out: &mut Vec<String>) {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
walk(base, &path, out);
|
|
} else {
|
|
out.push(
|
|
path.strip_prefix(base)
|
|
.unwrap_or(&path)
|
|
.to_string_lossy()
|
|
.replace('\\', "/"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
let mut out = Vec::new();
|
|
walk(root, root, &mut out);
|
|
out.sort();
|
|
out
|
|
}
|
|
|
|
/// 基线相对实例根的路径(`/` 分隔,给 `list_tree` 断言用)。
|
|
///
|
|
/// 从 `PackBaseline::relative_path()` 派生,**不在测试里手抄**目录名——手抄的那一份正是
|
|
/// 复审 r5 P1-1 的成因(读写两条路径各写各的)。
|
|
pub(crate) fn baseline_rel() -> String {
|
|
crate::addon::import::PackBaseline::relative_path()
|
|
.to_string_lossy()
|
|
.replace('\\', "/")
|
|
}
|
|
|
|
/// 读一个文件成字符串(断言覆盖内容用)。
|
|
pub(crate) fn read_text(path: &Path) -> String {
|
|
let bytes =
|
|
std::fs::read(path).unwrap_or_else(|error| panic!("读 {} 失败:{error}", path.display()));
|
|
String::from_utf8_lossy(&bytes).to_string()
|
|
}
|
|
|
|
/// 断言目录不存在(= 零残留)。
|
|
pub(crate) fn assert_absent(path: &Path) {
|
|
assert!(
|
|
!path.exists(),
|
|
"目标目录应当不存在(零残留),实际:{:?}",
|
|
list_tree(path)
|
|
);
|
|
}
|