fix(core): M110 r1 复审修复:导出安全闸、跨侧重名、第二检用例、半包与失败分级

按 tower 裁定清单逐条改,不扩战场(模型与扩展点未动)。

- p2-1 跨格式导出不再静默丢内容:`PackFormat::check_overrides` 在默认 `write` 写第一个
  字节前判定"这份模型的覆盖目录集合能否被本格式表达"。mrpack 要求目录名由 side 决定
  (overrides / client-overrides / server-overrides)、CF 只接受单一 Both 侧组;表达不了
  即 `pack_unsupported` + hint。`format.rs` 模块文档写死"默认 write 只保证同格式往返",
  并列出 M112 必须补的四件事(目录映射、side 合并、加载器 id 规范化、extra 分桶)。
- p2-2 `duplicate_override_paths` 改为按 `path` 跨侧计数,返回 `(路径, 侧集合)`;
  Both×Client 同路径这条 mrpack 真实冲突现在查得出来。
- p2-3 补 `#[cfg(windows)]` 第二检用例(CON / NUL / COM1.txt / `a.` / 尾空格 / 通配符),
  并订正 `archive.rs` 文档里"保留设备名这里不拦"的自相矛盾(实际由 enclosed_name 在
  Windows 宿主上拦,第一检放行)。
- p3-1 导出改临时文件 + rename,失败清理临时文件:目标路径不再留半个 zip。
- p3-2 三个 `detect` 不再 `.ok().flatten()` 吞错误:清单在但读不出来 = `pack_metadata`;
  注册表汇总时"命中优先于失败",一个都不命中才报,最后才退化成 `pack_unsupported`。
- p3-3 `env_extra`、CF `files[].extra`、`PackLoader::extra` 各补一条读→render→写→再读用例。
- p3-4 新增 `ReadBudget`(Cell<u64>):`override_files` 持有累计预算,按实际读出字节
  逐条扣减,声明大小撒谎的包不能靠"很多条各自合规"绕过 `max_uncompressed_bytes`。
- CF 读侧始终产出一个 Both 侧 overrides 组(字段缺省 `overrides`),与 render 对称。

每处修复都有变异证据(改坏 → 对应用例红):p2-1/p2-2/p2-3/p3-1/p3-2/p3-3a-c/p3-4 共 9 条。
门禁:fmt 0 / clippy --workspace --all-targets -D warnings 0 / cargo test --workspace -j 4
exit 0(58 个 test binary,passed 1930,failed 0);全部改动文件 ≤400 行(最大 389)。
This commit is contained in:
AskaEth committed 2026-11-04 00:20:24 +08:00
1 parent b34e86bba5
commit 6dd652aced
16 files changed
+804 -98

No files matched your search

+65 -2
View File
@@ -1,5 +1,14 @@
//! 读取限额:条目数 / 解压总体积 / 单条目,以及两处封顶实现。
//!
//! # 两阶段限额(打开 vs 读取)
//!
//! 打开阶段([`check_limits`])只能拿**中央目录声明的大小**做判断——它会撒谎。
//! 因此读取阶段还有第二道:单条目按实际读出字节封顶([`read_capped`]),批量读取
//! ([`super::PackArchive::override_files`])再按**累计实际字节**从 [`ReadBudget`] 里扣减。
//! 没有预算的批量读取等于把 `max_uncompressed_bytes` 降级成"每条单独算"——一个 2 万条目、
//! 每条声明 1 字节却解出几十 MiB 的包能把 CPU/时间耗在这里(r1 p3-4)。
use std::cell::Cell;
use std::io::Read;
use super::super::error::{PackError, PackResult};
@@ -48,6 +57,47 @@ impl Default for PackLimits {
}
}
/// 一次批量读取的**累计**预算(读取阶段,按实际读出的字节扣减)。
///
/// 为什么需要它:中央目录声明的大小可以撒谎,单个条目也被限制在
/// [`PackLimits::max_entry_bytes`],但"很多条各自合规"累起来仍可能远超
/// `max_uncompressed_bytes`。批量读取(`override_files`)持有一个预算,边读边扣。
#[derive(Debug)]
pub struct ReadBudget {
remaining: Cell<u64>,
}
impl ReadBudget {
/// 按总预算新建。
#[must_use]
pub fn new(limit: u64) -> Self {
Self {
remaining: Cell::new(limit),
}
}
/// 还剩多少字节。
#[must_use]
pub fn remaining(&self) -> u64 {
self.remaining.get()
}
/// 扣减一次实际读出的字节数;超额报 `pack_limits`。
///
/// 超额时预算**照样扣到 0**(这些字节已经从流里读出来了),后续调用会继续失败——
/// 不给"失败一次、下次又能读一点"的机会。
pub(super) fn spend(&self, bytes: u64, name: &str) -> PackResult<()> {
let remaining = self.remaining.get();
self.remaining.set(remaining.saturating_sub(bytes));
if bytes > remaining {
return Err(PackError::limits(format!(
"{name} 之后累计解压超过上限(本次还需 {bytes} 字节,预算已用尽)"
)));
}
Ok(())
}
}
/// 累计与单条目限额检查(打开阶段)。
pub(super) fn check_limits(
limits: &PackLimits,
@@ -79,9 +129,18 @@ pub(super) fn check_limits(
}
/// 带封顶的读:中央目录里声明的大小可以撒谎,这里按**实际读出的字节**封顶。
pub(super) fn read_capped(reader: &mut impl Read, cap: u64, name: &str) -> PackResult<Vec<u8>> {
///
/// 给了 `budget` 就同时从累计预算里扣(批量读取);单条读取给 `None`,
/// 只受 [`PackLimits::max_entry_bytes`] 约束。
pub(super) fn read_capped(
reader: &mut impl Read,
cap: u64,
budget: Option<&ReadBudget>,
name: &str,
) -> PackResult<Vec<u8>> {
let mut bytes = Vec::new();
let mut buffer = [0_u8; 64 * 1024];
let mut total = 0_u64;
loop {
let read = reader
.read(&mut buffer)
@@ -89,11 +148,15 @@ pub(super) fn read_capped(reader: &mut impl Read, cap: u64, name: &str) -> PackR
if read == 0 {
break;
}
if bytes.len() as u64 + read as u64 > cap {
total += read as u64;
if total > cap {
return Err(PackError::limits(format!(
"{name} 实际解压超过单条目上限 {cap}"
)));
}
if let Some(budget) = budget {
budget.spend(read as u64, name)?;
}
bytes.extend_from_slice(&buffer[..read]);
}
Ok(bytes)