diff --git a/crates/core/src/addon/pack.rs b/crates/core/src/addon/pack.rs
index f4a8bcc..a308012 100644
--- a/crates/core/src/addon/pack.rs
+++ b/crates/core/src/addon/pack.rs
@@ -14,6 +14,13 @@
//! 三个内置格式:[`curseforge`](`manifest.json`,可读可写)、[`modrinth`](`.mrpack`,可读可写)、
//! [`multimc`](`mmc-pack.json`,**只读**)。
//!
+//! # 导出面的边界(r1 之后写死)
+//!
+//! 默认 [`format::PackFormat::write`] **只保证同格式往返**:覆盖目录按模型原样搬,
+//! 不做目录映射 / `side` 分派 / 加载器 id 规范化 / `extra` 分桶(都属于 M112)。
+//! 表达不了的时候由 [`format::PackFormat::check_overrides`] 直接报 `pack_unsupported`,
+//! 而不是产出一个"成功但内容为空"的包。
+//!
//! # 刻意不做的事(分工边界)
//!
//! - **不发网络请求**:CF 的 `projectID/fileID` → 文件名/URL 补全属于 M111(要用 CF API 与
@@ -53,7 +60,7 @@ pub mod path;
#[cfg(test)]
mod tests;
-pub use archive::{PackArchive, PackEntryInfo, PackLimits};
+pub use archive::{PackArchive, PackEntryInfo, PackLimits, ReadBudget};
pub use curseforge::CurseForge;
pub use error::{PackError, PackErrorKind, PackResult};
pub use format::{PackFormat, PackMatch, PackPayload, PackRegistry};
diff --git a/crates/core/src/addon/pack/archive.rs b/crates/core/src/addon/pack/archive.rs
index a34740e..141d6d7 100644
--- a/crates/core/src/addon/pack/archive.rs
+++ b/crates/core/src/addon/pack/archive.rs
@@ -9,18 +9,24 @@
//!
//! | 威胁 | 做法 |
//! |------|------|
-//! | 路径穿越(`../x`、`/etc/x`、`C:\x`、`a\..\b`、`file:ads`) | 条目名过 [`normalize_file`](第一检),再过 `zip` crate 的 `enclosed_name()`(第二检:它按 **Windows 规则**解析盘符/ADS/保留设备名);任一拒绝即整包拒绝 |
+//! | 路径穿越(`../x`、`/etc/x`、`C:\x`、`a\..\b`、`file:ads`) | 条目名过 [`normalize_file`](第一检,平台无关的七条规则),再过 `zip` crate 的 `enclosed_name()`(第二检,**按 Windows 规则**解析盘符/ADS/保留设备名);任一拒绝即整包拒绝 |
//! | 符号链接条目 | `unix_mode()` 的 `S_IFLNK` 位命中即拒绝(Windows 上无合法用途;解压器会把目标当内容写出来) |
//! | 条目数爆炸 | [`PackLimits::max_entries`],打开时按中央目录长度先判 |
//! | 解压体积爆炸(zip 炸弹) | [`PackLimits::max_uncompressed_bytes`] 累计 + [`PackLimits::max_entry_bytes`] 单条目;**读取时**再按实际读出的字节数封顶(中央目录里声明的大小可以撒谎) |
//! | 同名条目(`./a` 与 `a` 规整后撞车) | 规整后的名字重复即拒绝 |
//!
-//! **为什么第一检不能省**:`enclosed_name()` 允许"深度 0 处的根目录",也就是
-//! `"/etc/passwd"` 会返回 `Some("/etc/passwd")`——绝对路径必须由我们自己的规则先干掉,
-//! 库的检查只当跨平台兜底。
+//! **两条检查各补一条对方没有的规则**(谁也不能省):
//!
-//! **不在这里做的事**:不写盘(那是 M111 导入管线的事),所以 Windows 保留设备名
-//! (`CON` / `NUL` / `COM1`…)这里不拦——落盘前必须拦,见 `zip::path` 的同名规则。
+//! - 第一检管**平台无关**的形状:空名、`\0`、`\`、`:`、前导 `/`、任何 `..` 段。
+//! `enclosed_name()` 在这一点上更松——它允许"深度 0 处的根目录",也就是 `"/etc/passwd"`
+//! 会返回 `Some("/etc/passwd")`,绝对路径必须由我们先干掉。
+//! - 第二检管 **Win32 命名空间**:DOS 设备名(`CON` / `NUL` / `AUX` / `COM1..9` / `LPT1..9`)、
+//! 结尾的点或空格(`a.`、`a `)、`< > " | ? *` 与控制字符。这些第一检全放行,而它们会让
+//! 落盘时撞到设备、或与另一个名字等价。第二检只在 **Windows 宿主**上生效(`zip` 的
+//! `cfg(windows)` 分支),所以 `tests/safety.rs` 里对应的用例也是 `#[cfg(windows)]`。
+//!
+//! 也就是说:`CON` / `NUL` 这类在 Windows 上**是拦的**(由第二检),非 Windows 宿主上不拦
+//! (那里它们只是普通文件名,拦了反而拒掉合法包)。
use std::cell::RefCell;
use std::collections::HashMap;
@@ -43,7 +49,8 @@ mod open;
pub use entry::PackEntryInfo;
pub use limits::{
- PackLimits, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES, DEFAULT_MAX_UNCOMPRESSED_BYTES,
+ PackLimits, ReadBudget, DEFAULT_MAX_ENTRIES, DEFAULT_MAX_ENTRY_BYTES,
+ DEFAULT_MAX_UNCOMPRESSED_BYTES,
};
use limits::read_capped;
@@ -160,7 +167,7 @@ impl PackArchive {
let mut reader = archive
.by_name(&entry.raw)
.map_err(|error| PackError::metadata(format!("解压 {name} 失败:{error}")))?;
- let bytes = read_capped(&mut reader, self.limits.max_entry_bytes, name)?;
+ let bytes = read_capped(&mut reader, self.limits.max_entry_bytes, None, name)?;
Ok(Some(bytes))
}
}
@@ -187,7 +194,15 @@ impl PackArchive {
}
/// 算一个条目的 SHA-1;不存在返回 `Ok(None)`。
+ ///
+ /// 单条读取只受 [`PackLimits::max_entry_bytes`] 约束;要跨条目累计限额的批量读取
+ /// 请用 [`Self::override_files`],或自己建 [`ReadBudget`] 交给 [`Self::override_files_with`]。
pub fn sha1(&self, name: &str) -> PackResult