feat(core): M152 整合包收尾——同路径遮蔽逐文件上报 + ZIP64 拒写预检
导入侧把「同名文件冲突」从整包一个策略推进到逐文件事实:同一实例路径被本包多份声明 命中时(跨侧 overrides/client-overrides,或同侧重复),按叠层顺序(Both → Client)定 胜者,其余标 PlanOverride::shadowed(winner / winner_dir / same_bytes),逐条留 pack.import.note.override_shadowed 注记;phase 只遍历未被遮蔽的条目,不再白读字节, 报告里也不再出现"两条 Installed、前一条的哈希其实没落地"。 不新增 FileAction/ConflictReason 变体:这三个枚举被 crates/presenter 的 convert.rs 穷尽 match,而那个 crate 不在本 purpose 的作用域内,所以走既有的 ImportIssue 通道 (与 server-overrides/ 被跳过同一条)。 导出侧那条 ZIP64 闸原来够不着:先 std::fs::read 整个覆盖文件进内存再累加判断,真要 导出 4 GiB 文件的实例,内存先炸、用户永远看不到那句 pack_limits。改成写前按 metadata().len() 做声明尺寸预检,超阈值在任何字节进内存之前就报;写循环里的增量检查 留作第二道。写 zip 的实现搬到 format/write.rs 以守 400 行。 测试:pack/tests/zip64.rs(稀疏文件真跑到 ZIP64 分支,含累计口径与对照)、 tests/import_layering.rs(同路径不同哈希 / 同哈希不同路径 / 叠层顺序 / 基线记胜者)、 unknown_fields.rs 补第 4 条假边(mrpack files[].extra + 未知哈希算法)。 五条变异各自变红(遮蔽 no-op 6 红、按哈希判重 6 红、翻转叠层 2 红、错误码改 io 3 红、 预检不累计则预检单测红且端到端用例从 0.01s 变成 95s/约 4.5 GiB 内存)。
This commit is contained in:
1 parent
37de00b07e
commit
d728ed836f
12 files changed
+847
-99
No files matched your search
@@ -51,9 +51,7 @@
|
||||
//! 仍然不做映射(那是格式之上的事),但"跨格式导出无路可走"已经不再成立。
|
||||
|
||||
use std::fmt::Debug;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use super::archive::PackArchive;
|
||||
use super::curseforge;
|
||||
@@ -61,9 +59,9 @@ use super::error::{PackError, PackResult};
|
||||
use super::manifest::PackManifest;
|
||||
use super::modrinth;
|
||||
use super::multimc;
|
||||
use super::path::{normalize_dir, normalize_file};
|
||||
|
||||
mod payload;
|
||||
mod write;
|
||||
|
||||
/// 导出时的覆盖内容来源在 [`payload`] 子模块里(`payload.rs`,与本文件拆开是为了守 400 行):
|
||||
/// [`PackPayload::metadata_only`] 只写清单、[`PackPayload::from_root`] 按
|
||||
@@ -116,7 +114,8 @@ pub trait PackFormat: Debug + Send + Sync {
|
||||
/// 导出一个整包(清单 + 覆盖内容)。
|
||||
///
|
||||
/// 默认实现做四件事:①只读格式直接拒;②[`Self::check_overrides`] 挡跨格式导出;
|
||||
/// ③`render` 出清单;④写进**临时文件**再 `rename` 到 `target`(失败不留半个包)。
|
||||
/// ③`render` 出清单;④**先按声明尺寸预检 ZIP64 阈值**,再写进**临时文件**并 `rename` 到
|
||||
/// `target`(失败不留半个包)。第④步的细节见 `format::write` 模块文档。
|
||||
///
|
||||
/// # 只保证同格式往返(写给 M111/M112)
|
||||
///
|
||||
@@ -148,8 +147,8 @@ pub trait PackFormat: Debug + Send + Sync {
|
||||
manifest.check_paths()?;
|
||||
let json = self.render(manifest)?;
|
||||
// 临时文件 + rename:中途失败时目标路径上不会留下"看起来能用"的半个 zip(r1 p3-1)。
|
||||
let temp = temporary_path(target);
|
||||
match write_container(self.id(), entry, &json, manifest, payload, &temp) {
|
||||
let temp = write::temporary_path(target);
|
||||
match write::container(self.id(), entry, &json, manifest, payload, &temp) {
|
||||
Ok(()) => std::fs::rename(&temp, target).map_err(|error| {
|
||||
let _ = std::fs::remove_file(&temp);
|
||||
PackError::io(format!(
|
||||
@@ -166,81 +165,6 @@ pub trait PackFormat: Debug + Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// 临时文件路径(与目标同目录,`rename` 才能原子替换)。
|
||||
fn temporary_path(target: &Path) -> PathBuf {
|
||||
let mut name = target.file_name().map_or_else(
|
||||
|| std::ffi::OsString::from("pack"),
|
||||
std::ffi::OsStr::to_os_string,
|
||||
);
|
||||
name.push(format!(".{}.part", std::process::id()));
|
||||
target.with_file_name(name)
|
||||
}
|
||||
|
||||
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
|
||||
fn write_container(
|
||||
format_id: &str,
|
||||
entry: &'static str,
|
||||
json: &str,
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
temp: &Path,
|
||||
) -> PackResult<()> {
|
||||
let file = File::create(temp)
|
||||
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o644);
|
||||
writer
|
||||
.start_file(entry, options)
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(json.as_bytes())
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
// 未压缩总量封顶在 ZIP64 阈值(≈4 GiB):Modrinth 官方导出也是这样拒绝的,而不是
|
||||
// 静默截断(HMCL 那边 `(int)` 截断只打 warning 是反面教材)。临时文件由调用方删。
|
||||
let mut total = json.len() as u64;
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
for override_file in &group.files {
|
||||
let path = normalize_file(&override_file.path)?;
|
||||
let name = format!("{dir}/{path}");
|
||||
let source = match payload.source_for(&name) {
|
||||
Some(source) => source.to_path_buf(),
|
||||
None => {
|
||||
let Some(root) = payload.root() else {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录"
|
||||
)));
|
||||
};
|
||||
root.join(&dir).join(&path)
|
||||
}
|
||||
};
|
||||
let bytes = std::fs::read(&source).map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?;
|
||||
total += bytes.len() as u64;
|
||||
if total >= zip::ZIP64_BYTES_THR {
|
||||
return Err(PackError::limits(format!(
|
||||
"包体未压缩总量已超过 {} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包",
|
||||
zip::ZIP64_BYTES_THR
|
||||
))
|
||||
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
|
||||
}
|
||||
writer
|
||||
.start_file(name.clone(), options)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(&bytes)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|error| PackError::io(format!("收尾 zip 失败:{error}(格式 {format_id})")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 一次探测的结果。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PackMatch {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 把模型写成 zip:**声明尺寸预检 → 写清单 → 逐条搬覆盖内容**(`format.rs` 只留 trait 与注册表)。
|
||||
//!
|
||||
//! # 为什么预检必须在读第一个字节之前
|
||||
//!
|
||||
//! 一条写死的边界(research/18-p2 §8.6,A 级 `export_mrpack.rs:305-311`):**不写 ZIP64 包**。
|
||||
//! Modrinth 官方导出撞到阈值直接报错,HMCL 那边 `(int)` 截断只打 warning 是反面教材。
|
||||
//!
|
||||
//! 早先的实现是「读一个文件 → 累加 → 比较」:真要导出一个 4 GiB 覆盖文件的实例,
|
||||
//! `std::fs::read` 先把它整份读进内存,闸门**永远够不着**——用户看到的是内存打爆或 IO 错,
|
||||
//! 而不是"包太大,把这个文件移出实例"。所以现在先跑一遍 `metadata().len()` 累加
|
||||
//! (`preflight`),超阈值在任何内容被读进内存**之前**就报 `pack_limits`。
|
||||
//!
|
||||
//! 写循环里那份增量检查保留,当第二道:预检与写入之间文件可能变大(实例目录不是快照)。
|
||||
//!
|
||||
//! # 阈值与声明口径
|
||||
//!
|
||||
//! 判据是**未压缩声明的总量**(清单 JSON + 每个覆盖文件的字节数)≥ `zip::ZIP64_BYTES_THR`
|
||||
//! (≈4 GiB)。zip 条目头与中央目录的几十字节开销不计——这个常数本身就是近似边界,
|
||||
//! 官方实现同样按这个粗粒度判。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::super::error::{PackError, PackResult};
|
||||
use super::super::manifest::PackManifest;
|
||||
use super::super::path::{normalize_dir, normalize_file};
|
||||
use super::payload::PackPayload;
|
||||
|
||||
/// 与目标同目录的临时文件路径(同卷 `rename` 才能原子替换)。
|
||||
pub(super) fn temporary_path(target: &Path) -> PathBuf {
|
||||
let mut name = target.file_name().map_or_else(
|
||||
|| std::ffi::OsString::from("pack"),
|
||||
std::ffi::OsStr::to_os_string,
|
||||
);
|
||||
name.push(format!(".{}.part", std::process::id()));
|
||||
target.with_file_name(name)
|
||||
}
|
||||
|
||||
/// 真正写 zip 的那一段(失败了由调用方删临时文件)。
|
||||
pub(super) fn container(
|
||||
format_id: &str,
|
||||
entry: &'static str,
|
||||
json: &str,
|
||||
manifest: &PackManifest,
|
||||
payload: PackPayload<'_>,
|
||||
temp: &Path,
|
||||
) -> PackResult<()> {
|
||||
// 先判总量再碰磁盘:超限时临时文件都不建,更不会读进一份 4 GiB 的内容。
|
||||
let mut total = preflight(json.len() as u64, manifest, payload)?;
|
||||
|
||||
let file = File::create(temp)
|
||||
.map_err(|error| PackError::io(format!("创建 {} 失败:{error}", temp.display())))?;
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.unix_permissions(0o644);
|
||||
writer
|
||||
.start_file(entry, options)
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(json.as_bytes())
|
||||
.map_err(|error| PackError::io(format!("写 {entry} 失败:{error}")))?;
|
||||
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
for override_file in &group.files {
|
||||
let path = normalize_file(&override_file.path)?;
|
||||
let name = format!("{dir}/{path}");
|
||||
let source = source_for(payload, &dir, &path)?;
|
||||
let bytes = std::fs::read(&source).map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?;
|
||||
total += bytes.len() as u64;
|
||||
guard(total)?;
|
||||
writer
|
||||
.start_file(name.clone(), options)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
writer
|
||||
.write_all(&bytes)
|
||||
.map_err(|error| PackError::io(format!("写 {name} 失败:{error}")))?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(|error| PackError::io(format!("收尾 zip 失败:{error}(格式 {format_id})")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 写盘前的**声明尺寸预检**:只量 `metadata().len()`,不读内容。
|
||||
///
|
||||
/// 返回清单 + 全部覆盖文件的声明总量(供写循环接着累加当第二道)。
|
||||
fn preflight(json_len: u64, manifest: &PackManifest, payload: PackPayload<'_>) -> PackResult<u64> {
|
||||
let mut total = json_len;
|
||||
guard(total)?;
|
||||
for group in &manifest.overrides {
|
||||
let dir = normalize_dir(&group.dir)?;
|
||||
for override_file in &group.files {
|
||||
let path = normalize_file(&override_file.path)?;
|
||||
let source = source_for(payload, &dir, &path)?;
|
||||
// 缺文件 / 读不到元数据在这里就报(与"读内容失败"同一个 `pack_io` 口径:
|
||||
// 目标路径上不会留下半个包,临时文件由调用方删)。
|
||||
let length = std::fs::metadata(&source)
|
||||
.map_err(|error| {
|
||||
PackError::io(format!("读覆盖文件 {} 失败:{error}", source.display()))
|
||||
})?
|
||||
.len();
|
||||
total += length;
|
||||
guard(total)?;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// 单条覆盖文件的来源:来源表优先(跨格式导出用),否则按 `<root>/<dir>/<path>` 拼。
|
||||
fn source_for(payload: PackPayload<'_>, dir: &str, path: &str) -> PackResult<PathBuf> {
|
||||
let name = format!("{dir}/{path}");
|
||||
if let Some(source) = payload.source_for(&name) {
|
||||
return Ok(source.to_path_buf());
|
||||
}
|
||||
let Some(root) = payload.root() else {
|
||||
return Err(PackError::unsupported(format!(
|
||||
"导出需要覆盖内容({name}),但既没有来源映射也没有 payload 根目录"
|
||||
)));
|
||||
};
|
||||
Ok(root.join(dir).join(path))
|
||||
}
|
||||
|
||||
/// ZIP64 阈值闸(`pack_limits`)。
|
||||
fn guard(total: u64) -> PackResult<()> {
|
||||
guard_with(total, zip::ZIP64_BYTES_THR)
|
||||
}
|
||||
|
||||
/// 判据本体:`total` **达到**阈值就拒(`>=` 不是 `>`,官方实现也是"撞上就不写")。
|
||||
///
|
||||
/// 抽出 `limit` 参数只为了能在小数值上钉住这条比较——端到端用例必须造 4 GiB 的声明尺寸,
|
||||
/// 而"`>=` 还是 `>`"这种一位之差在小数值上就能验。
|
||||
fn guard_with(total: u64, limit: u64) -> PackResult<()> {
|
||||
if total >= limit {
|
||||
return Err(PackError::limits(format!(
|
||||
"包体未压缩总量已超过 {limit} 字节(ZIP64 阈值),拒绝写出一个需要 ZIP64 的整合包"
|
||||
))
|
||||
.with_hint("把超大的覆盖文件移出实例后再导出;mrpack/CF 都不接受 ZIP64"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::addon::pack::manifest::{OverrideFile, PackOverrides, PackSide};
|
||||
|
||||
#[test]
|
||||
fn the_threshold_is_reached_not_exceeded() {
|
||||
// `>=` 而不是 `>`:恰好等于阈值就已经需要 ZIP64 了。
|
||||
assert!(guard_with(99, 100).is_ok());
|
||||
let error = guard_with(100, 100).expect_err("等于阈值必须拒");
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
super::super::super::error::PackErrorKind::Limits
|
||||
);
|
||||
assert!(error.message().contains("100"), "{error}");
|
||||
assert!(guard_with(101, 100).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preflight_measures_declared_sizes_not_content() {
|
||||
// 预检必须只量 `metadata().len()`:这一条用"小文件 + 3 GiB 稀疏文件"钉住,
|
||||
// 真读内容的话要么慢要么直接吃 3 GiB 内存。
|
||||
let root = std::env::temp_dir().join(format!("ull-preflight-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
|
||||
std::fs::write(root.join("overrides/mods/small.jar"), b"0123456789").expect("写小文件");
|
||||
let huge = root.join("overrides/mods/huge.jar");
|
||||
let file = std::fs::File::create(&huge).expect("建稀疏文件");
|
||||
file.set_len(3 * 1024 * 1024 * 1024).expect("拉长(稀疏)");
|
||||
drop(file);
|
||||
|
||||
let mut manifest = PackManifest::new("preflight");
|
||||
manifest.overrides.push(PackOverrides {
|
||||
dir: "overrides".to_string(),
|
||||
side: PackSide::Both,
|
||||
files: vec![
|
||||
OverrideFile {
|
||||
path: "mods/small.jar".to_string(),
|
||||
sha1: String::new(),
|
||||
size: 10,
|
||||
},
|
||||
OverrideFile {
|
||||
path: "mods/huge.jar".to_string(),
|
||||
sha1: String::new(),
|
||||
size: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let total = preflight(7, &manifest, PackPayload::from_root(&root))
|
||||
.expect("3 GiB 总量仍在阈值之下,预检不许误伤");
|
||||
assert_eq!(
|
||||
total,
|
||||
7 + 10 + 3 * 1024 * 1024 * 1024,
|
||||
"清单 + 每个覆盖文件的声明尺寸"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod model;
|
||||
mod roundtrip;
|
||||
mod safety;
|
||||
mod unknown_fields;
|
||||
mod zip64;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
@@ -111,3 +111,56 @@ fn curseforge_loader_unknown_keys_survive_read_write_reread() {
|
||||
assert_eq!(reread, manifest, "modLoaders[] 里的未知键必须原样回写");
|
||||
assert_eq!(reread.loaders[0].extra["cachedName"], json!("Forge 47.2.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mrpack_file_unknown_keys_survive_read_write_reread() {
|
||||
// 第四个假边(M152 补):`files[]` 这一层。上面三条覆盖了 `env` / CF `files[]` /
|
||||
// CF `modLoaders[]`,而 mrpack 的文件级未知键一直没人钉——它恰恰是"社区给某条文件加
|
||||
// 一个私有字段"最常出现的位置(例如镜像站塞的 `mirror` / 作者写的 `optionalReason`)。
|
||||
let root = super::temp_root("mr-file-extra");
|
||||
let dir = root.join("src");
|
||||
super::write_file(
|
||||
&dir.join("modrinth.index.json"),
|
||||
br#"{"game":"minecraft","formatVersion":1,"versionId":"1.0","name":"file extra keys",
|
||||
"files":[{"path":"mods/a.jar",
|
||||
"hashes":{"sha1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"sha512":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"md5":"d41d8cd98f00b204e9800998ecf8427e"},
|
||||
"downloads":["https://cdn.modrinth.com/data/abc/versions/x/a.jar"],
|
||||
"fileSize":42,
|
||||
"mirror":"https://mirror.example.invalid/a.jar",
|
||||
"optionalReason":{"why":"kept for the community"}}],
|
||||
"dependencies":{"minecraft":"1.20.1"}}"#,
|
||||
);
|
||||
let zip = root.join("pack.mrpack");
|
||||
super::zip_dir(&dir, &zip);
|
||||
|
||||
let pack = PackArchive::open_default(&zip).expect("打开包");
|
||||
let manifest = MODRINTH.read(&pack).expect("读 mrpack 清单");
|
||||
assert_eq!(
|
||||
manifest.files[0].extra["mirror"],
|
||||
json!("https://mirror.example.invalid/a.jar")
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.files[0].extra["optionalReason"],
|
||||
json!({"why": "kept for the community"})
|
||||
);
|
||||
// 未知**哈希算法**也不丢:`hashes` 是整张表,不是两个字段。
|
||||
assert_eq!(
|
||||
manifest.files[0].hashes.get("md5").map(String::as_str),
|
||||
Some("d41d8cd98f00b204e9800998ecf8427e")
|
||||
);
|
||||
|
||||
let target = root.join("out.mrpack");
|
||||
MODRINTH
|
||||
.write(&manifest, PackPayload::metadata_only(), &target)
|
||||
.expect("导出 mrpack");
|
||||
let reread = MODRINTH
|
||||
.read(&PackArchive::open_default(&target).expect("打开导出包"))
|
||||
.expect("再读");
|
||||
assert_eq!(reread, manifest, "files[] 里的未知键必须原样回写");
|
||||
assert_eq!(
|
||||
reread.files[0].extra["optionalReason"],
|
||||
json!({"why": "kept for the community"})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! ZIP64 拒写(research/18-p2 §8.6 / 表 B #17 的导出侧那条)。
|
||||
//!
|
||||
//! # 为什么这个文件以前是空的
|
||||
//!
|
||||
//! M112 的完成报告写明:「ZIP64 阈值那条分支只有代码没有端到端测试(要造 4 GiB 数据)」。
|
||||
//! 而更要紧的是——**那条分支当时根本够不着**:实现先 `std::fs::read` 整个覆盖文件进内存,
|
||||
//! 再累加比较,所以真要导出一个 4 GiB 文件的实例,内存先炸,用户永远看不到那句
|
||||
//! `pack_limits`。现在写侧改成「先按 `metadata().len()` 预检」,两个问题一起解决:
|
||||
//!
|
||||
//! - 边界是真的会在读第一个字节之前拦下来的(本文件的用例就断言这一点:失败后临时目录里
|
||||
//! 没有 `.part`、目标路径不存在);
|
||||
//! - 测试可以用**稀疏文件**造出 4 GiB 的"声明尺寸"而几乎不占磁盘(`set_len` 只改 EOF,
|
||||
//! NTFS / ext4 都不会真写零)。这也是唯一能在不塞满硬盘的前提下打通这条分支的办法。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::temp_root;
|
||||
use crate::addon::pack::curseforge::CURSEFORGE;
|
||||
use crate::addon::pack::error::PackErrorKind;
|
||||
use crate::addon::pack::format::{PackFormat, PackPayload};
|
||||
use crate::addon::pack::manifest::{OverrideFile, PackManifest, PackOverrides, PackSide};
|
||||
|
||||
/// 一个覆盖文件的声明(内容由调用方提供)。
|
||||
fn override_file(path: &str) -> OverrideFile {
|
||||
OverrideFile {
|
||||
path: path.to_string(),
|
||||
sha1: "0".repeat(40),
|
||||
size: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_of(files: Vec<OverrideFile>) -> PackManifest {
|
||||
let mut manifest = PackManifest::new("超大包");
|
||||
manifest.version = Some("1.0.0".to_string());
|
||||
manifest.minecraft = Some("1.20.1".to_string());
|
||||
manifest.overrides.push(PackOverrides {
|
||||
dir: "overrides".to_string(),
|
||||
side: PackSide::Both,
|
||||
files,
|
||||
});
|
||||
manifest
|
||||
}
|
||||
|
||||
/// 造一个只占元数据的 `length` 字节文件(`set_len` 不写零)。
|
||||
fn sparse_file(path: &Path, length: u64) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("建目录");
|
||||
}
|
||||
let file = std::fs::File::create(path).expect("建稀疏文件");
|
||||
file.set_len(length).expect("拉长文件(稀疏)");
|
||||
}
|
||||
|
||||
/// 临时目录里除 `keep` 之外的文件名(断言"没留下 .part"用)。
|
||||
fn leftovers(root: &Path, keep: &[&str]) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(root)
|
||||
.expect("列目录")
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.file_name().to_string_lossy().to_string())
|
||||
.filter(|name| !keep.contains(&name.as_str()))
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_file_over_the_threshold_is_refused_before_a_single_byte_is_read() {
|
||||
let root = temp_root("zip64-single");
|
||||
// 恰好一个字节超过阈值:预检必须在这里就报,不许读内容(真读会先要 4 GiB 内存)。
|
||||
sparse_file(
|
||||
&root.join("overrides/mods/huge.jar"),
|
||||
zip::ZIP64_BYTES_THR + 1,
|
||||
);
|
||||
let manifest = manifest_of(vec![override_file("mods/huge.jar")]);
|
||||
let target = root.join("out.zip");
|
||||
|
||||
let error = CURSEFORGE
|
||||
.write(&manifest, PackPayload::from_root(&root), &target)
|
||||
.expect_err("超 ZIP64 阈值必须拒写");
|
||||
|
||||
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
|
||||
assert!(error.message().contains("ZIP64"), "{error}");
|
||||
assert!(
|
||||
error.hint().is_some_and(|hint| hint.contains("移出实例")),
|
||||
"要给「把大文件移出实例」这种可执行的提示:{error:?}"
|
||||
);
|
||||
assert!(!target.exists(), "失败后目标路径不许存在");
|
||||
// 临时文件也不许留(预检在建临时文件之前就失败 ⇒ 目录里只有源文件)。
|
||||
assert_eq!(leftovers(&root, &["overrides"]), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_threshold_is_cumulative_not_per_file() {
|
||||
// 三个文件各自都远小于阈值,加起来超了:判据是**整包未压缩总量**,不是单文件。
|
||||
let root = temp_root("zip64-cumulative");
|
||||
let each = 1_610_612_736_u64; // 1.5 GiB
|
||||
assert!(each < zip::ZIP64_BYTES_THR, "单条必须都在阈值以下");
|
||||
assert!(each * 3 > zip::ZIP64_BYTES_THR, "三条加起来必须超标");
|
||||
let mut files = Vec::new();
|
||||
for index in 0..3 {
|
||||
let relative = format!("mods/part-{index}.jar");
|
||||
sparse_file(&root.join("overrides").join(&relative), each);
|
||||
files.push(override_file(&relative));
|
||||
}
|
||||
|
||||
let target = root.join("out.zip");
|
||||
let error = CURSEFORGE
|
||||
.write(&manifest_of(files), PackPayload::from_root(&root), &target)
|
||||
.expect_err("累计超阈值同样必须拒写");
|
||||
|
||||
assert_eq!(error.kind(), PackErrorKind::Limits, "{error}");
|
||||
assert!(error.message().contains("ZIP64"), "{error}");
|
||||
assert!(!target.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_under_the_threshold_still_writes_a_normal_pack() {
|
||||
// 对照:不超阈值时预检不许误伤(否则这条护栏就变成"导出全挂")。
|
||||
let root = temp_root("zip64-under");
|
||||
let content = b"a normal sized override file";
|
||||
std::fs::create_dir_all(root.join("overrides/mods")).expect("建目录");
|
||||
std::fs::write(root.join("overrides/mods/jei.jar"), content).expect("写覆盖文件");
|
||||
let manifest = manifest_of(vec![override_file("mods/jei.jar")]);
|
||||
let target = root.join("out.zip");
|
||||
|
||||
CURSEFORGE
|
||||
.write(&manifest, PackPayload::from_root(&root), &target)
|
||||
.expect("正常尺寸必须写得出来");
|
||||
assert!(target.exists());
|
||||
let written = crate::addon::pack::PackArchive::open_default(&target).expect("打开导出的包");
|
||||
assert!(written.has_entry("overrides/mods/jei.jar"));
|
||||
}
|
||||
Reference in new issue
Block a user