Files
BlueArchiveToolkit/infrastructure/src/zip_validation.rs
T

689 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! ZIP structure validation helpers.
use std::fs::{self, File};
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
const EOCD_SIGNATURE: u32 = 0x0605_4b50;
const ZIP64_EOCD_SIGNATURE: u32 = 0x0606_4b50;
const ZIP64_EOCD_LOCATOR_SIGNATURE: u32 = 0x0706_4b50;
const CENTRAL_DIRECTORY_SIGNATURE: u32 = 0x0201_4b50;
const LOCAL_FILE_HEADER_SIGNATURE: u32 = 0x0403_4b50;
const EOCD_MIN_LEN: u64 = 22;
const EOCD_SEARCH_WINDOW: u64 = EOCD_MIN_LEN + 65_535;
const ZIP64_EOCD_LOCATOR_LEN: u64 = 20;
const ZIP64_EOCD_FIXED_LEN: usize = 56;
const CENTRAL_DIRECTORY_FIXED_LEN: u64 = 46;
const LOCAL_FILE_HEADER_FIXED_LEN: u64 = 30;
const ZIP64_EXTENDED_INFORMATION_EXTRA_ID: u16 = 0x0001;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ZipStructureReport {
pub(crate) entry_count: u64,
pub(crate) uses_zip64: bool,
}
pub(crate) fn path_has_zip_extension(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
}
pub(crate) fn url_or_path_has_zip_extension(value: &str) -> bool {
let value = value.split(['?', '#']).next().unwrap_or(value);
value
.rsplit('/')
.next()
.is_some_and(|name| name.to_ascii_lowercase().ends_with(".zip"))
}
pub(crate) fn validate_zip_structure(path: &Path) -> Result<ZipStructureReport, String> {
let metadata = fs::metadata(path)
.map_err(|error| format!("读取 ZIP 文件信息失败 {}{error}", path.display()))?;
if !metadata.is_file() {
return Err(format!("ZIP 路径不是文件:{}", path.display()));
}
let file_len = metadata.len();
if file_len < EOCD_MIN_LEN {
return Err(format!(
"ZIP 文件过短,找不到 EOCD 记录 {}{} bytes",
path.display(),
file_len
));
}
let mut file = File::open(path)
.map_err(|error| format!("打开 ZIP 文件失败 {}{error}", path.display()))?;
let eocd = find_eocd(&mut file, file_len, path)?;
let central_directory = read_central_directory_descriptor(&mut file, &eocd, file_len, path)?;
validate_central_directory(&mut file, &central_directory, file_len, path)?;
Ok(ZipStructureReport {
entry_count: central_directory.entry_count,
uses_zip64: central_directory.uses_zip64,
})
}
fn find_eocd(file: &mut File, file_len: u64, path: &Path) -> Result<EocdRecord, String> {
let tail_len = file_len.min(EOCD_SEARCH_WINDOW);
let tail_start = file_len - tail_len;
let mut tail = vec![0u8; tail_len as usize];
file.seek(SeekFrom::Start(tail_start))
.map_err(|error| format!("定位 ZIP EOCD 搜索窗口失败 {}{error}", path.display()))?;
file.read_exact(&mut tail)
.map_err(|error| format!("读取 ZIP EOCD 搜索窗口失败 {}{error}", path.display()))?;
let min_offset = tail.len().saturating_sub(EOCD_MIN_LEN as usize);
for offset in (0..=min_offset).rev() {
if read_u32_le(&tail, offset)? != EOCD_SIGNATURE {
continue;
}
let comment_len = read_u16_le(&tail, offset + 20)? as u64;
let absolute_offset = tail_start + offset as u64;
if absolute_offset + EOCD_MIN_LEN + comment_len != file_len {
continue;
}
return Ok(EocdRecord {
offset: absolute_offset,
disk_number: read_u16_le(&tail, offset + 4)?,
central_directory_disk: read_u16_le(&tail, offset + 6)?,
disk_entry_count: read_u16_le(&tail, offset + 8)?,
total_entry_count: read_u16_le(&tail, offset + 10)?,
central_directory_size: read_u32_le(&tail, offset + 12)?,
central_directory_offset: read_u32_le(&tail, offset + 16)?,
});
}
Err(format!("ZIP 文件缺少有效 EOCD 记录:{}", path.display()))
}
fn read_central_directory_descriptor(
file: &mut File,
eocd: &EocdRecord,
file_len: u64,
path: &Path,
) -> Result<CentralDirectoryDescriptor, String> {
if eocd.disk_number != 0 || eocd.central_directory_disk != 0 {
return Err(format!("不支持多卷 ZIP 文件:{}", path.display()));
}
let requires_zip64 = eocd.disk_entry_count == u16::MAX
|| eocd.total_entry_count == u16::MAX
|| eocd.central_directory_size == u32::MAX
|| eocd.central_directory_offset == u32::MAX;
if !requires_zip64 {
if eocd.disk_entry_count != eocd.total_entry_count {
return Err(format!(
"ZIP EOCD 条目计数不一致 {}disk={} total={}",
path.display(),
eocd.disk_entry_count,
eocd.total_entry_count
));
}
return Ok(CentralDirectoryDescriptor {
entry_count: eocd.total_entry_count as u64,
size: eocd.central_directory_size as u64,
offset: eocd.central_directory_offset as u64,
suffix_offset: eocd.offset,
uses_zip64: false,
});
}
if eocd.offset < ZIP64_EOCD_LOCATOR_LEN {
return Err(format!("ZIP64 文件缺少 locator{}", path.display()));
}
let locator_offset = eocd.offset - ZIP64_EOCD_LOCATOR_LEN;
let locator = read_exact_at(file, locator_offset, ZIP64_EOCD_LOCATOR_LEN as usize, path)?;
if read_u32_le(&locator, 0)? != ZIP64_EOCD_LOCATOR_SIGNATURE {
return Err(format!("ZIP64 文件缺少有效 locator{}", path.display()));
}
let eocd_disk = read_u32_le(&locator, 4)?;
let zip64_eocd_offset = read_u64_le(&locator, 8)?;
let total_disks = read_u32_le(&locator, 16)?;
if eocd_disk != 0 || total_disks != 1 {
return Err(format!("不支持多卷 ZIP64 文件:{}", path.display()));
}
if zip64_eocd_offset >= file_len {
return Err(format!(
"ZIP64 EOCD 偏移越界 {}offset={}",
path.display(),
zip64_eocd_offset
));
}
let record = read_exact_at(file, zip64_eocd_offset, ZIP64_EOCD_FIXED_LEN, path)?;
if read_u32_le(&record, 0)? != ZIP64_EOCD_SIGNATURE {
return Err(format!("ZIP64 EOCD 签名无效:{}", path.display()));
}
let record_size = read_u64_le(&record, 4)?;
if record_size < 44 {
return Err(format!(
"ZIP64 EOCD 长度无效 {}{}",
path.display(),
record_size
));
}
let disk_number = read_u32_le(&record, 16)?;
let central_directory_disk = read_u32_le(&record, 20)?;
let disk_entry_count = read_u64_le(&record, 24)?;
let total_entry_count = read_u64_le(&record, 32)?;
if disk_number != 0 || central_directory_disk != 0 || disk_entry_count != total_entry_count {
return Err(format!("ZIP64 EOCD 多卷或条目计数无效:{}", path.display()));
}
let central_directory_size = read_u64_le(&record, 40)?;
let central_directory_offset = read_u64_le(&record, 48)?;
Ok(CentralDirectoryDescriptor {
entry_count: total_entry_count,
size: central_directory_size,
offset: central_directory_offset,
suffix_offset: zip64_eocd_offset,
uses_zip64: true,
})
}
fn validate_central_directory(
file: &mut File,
central_directory: &CentralDirectoryDescriptor,
file_len: u64,
path: &Path,
) -> Result<(), String> {
let central_end = checked_add(
central_directory.offset,
central_directory.size,
"ZIP central directory 边界溢出",
path,
)?;
if central_end > central_directory.suffix_offset || central_end > file_len {
return Err(format!(
"ZIP central directory 越界 {}offset={} size={} file_size={}",
path.display(),
central_directory.offset,
central_directory.size,
file_len
));
}
let mut cursor = central_directory.offset;
for index in 0..central_directory.entry_count {
if checked_add(
cursor,
CENTRAL_DIRECTORY_FIXED_LEN,
"ZIP central header 边界溢出",
path,
)? > central_end
{
return Err(format!(
"ZIP central directory 条目不足 {}index={}",
path.display(),
index
));
}
let header = read_exact_at(file, cursor, CENTRAL_DIRECTORY_FIXED_LEN as usize, path)?;
if read_u32_le(&header, 0)? != CENTRAL_DIRECTORY_SIGNATURE {
return Err(format!(
"ZIP central header 签名无效 {}offset={}",
path.display(),
cursor
));
}
let compressed_size_32 = read_u32_le(&header, 20)?;
let uncompressed_size_32 = read_u32_le(&header, 24)?;
let file_name_len = read_u16_le(&header, 28)? as u64;
let extra_len = read_u16_le(&header, 30)? as u64;
let comment_len = read_u16_le(&header, 32)? as u64;
let disk_start_16 = read_u16_le(&header, 34)?;
let local_header_offset_32 = read_u32_le(&header, 42)?;
let entry_end = checked_add(
checked_add(
checked_add(
cursor,
CENTRAL_DIRECTORY_FIXED_LEN,
"ZIP central header 文件名边界溢出",
path,
)?,
file_name_len,
"ZIP central header extra 边界溢出",
path,
)?,
extra_len + comment_len,
"ZIP central header comment 边界溢出",
path,
)?;
if entry_end > central_end {
return Err(format!(
"ZIP central header 条目越界 {}offset={}",
path.display(),
cursor
));
}
if file_name_len == 0 {
return Err(format!(
"ZIP central header 文件名为空 {}offset={}",
path.display(),
cursor
));
}
let name = read_exact_at(
file,
cursor + CENTRAL_DIRECTORY_FIXED_LEN,
file_name_len as usize,
path,
)?;
let extra = read_exact_at(
file,
cursor + CENTRAL_DIRECTORY_FIXED_LEN + file_name_len,
extra_len as usize,
path,
)?;
let zip64 = parse_zip64_extra(
&extra,
uncompressed_size_32 == u32::MAX,
compressed_size_32 == u32::MAX,
local_header_offset_32 == u32::MAX,
disk_start_16 == u16::MAX,
path,
)?;
let compressed_size = zip64.compressed_size.unwrap_or(compressed_size_32 as u64);
let local_header_offset = zip64
.local_header_offset
.unwrap_or(local_header_offset_32 as u64);
let disk_start = zip64.disk_start.unwrap_or(disk_start_16 as u32);
if disk_start != 0 {
return Err(format!("不支持多卷 ZIP 条目:{}", path.display()));
}
validate_local_file_header(
file,
path,
&name,
local_header_offset,
compressed_size,
central_directory.offset,
)?;
cursor = entry_end;
}
if cursor != central_end {
return Err(format!(
"ZIP central directory 大小与条目不匹配 {}parsed_end={} expected_end={}",
path.display(),
cursor,
central_end
));
}
Ok(())
}
fn validate_local_file_header(
file: &mut File,
path: &Path,
central_name: &[u8],
local_header_offset: u64,
compressed_size: u64,
central_directory_offset: u64,
) -> Result<(), String> {
if local_header_offset >= central_directory_offset {
return Err(format!(
"ZIP local header 偏移越界 {}offset={}",
path.display(),
local_header_offset
));
}
let header = read_exact_at(
file,
local_header_offset,
LOCAL_FILE_HEADER_FIXED_LEN as usize,
path,
)?;
if read_u32_le(&header, 0)? != LOCAL_FILE_HEADER_SIGNATURE {
return Err(format!(
"ZIP local header 签名无效 {}offset={}",
path.display(),
local_header_offset
));
}
let file_name_len = read_u16_le(&header, 26)? as u64;
let extra_len = read_u16_le(&header, 28)? as u64;
let data_start = checked_add(
checked_add(
checked_add(
local_header_offset,
LOCAL_FILE_HEADER_FIXED_LEN,
"ZIP local header 文件名边界溢出",
path,
)?,
file_name_len,
"ZIP local header extra 边界溢出",
path,
)?,
extra_len,
"ZIP local data 边界溢出",
path,
)?;
let data_end = checked_add(
data_start,
compressed_size,
"ZIP local data 压缩数据边界溢出",
path,
)?;
if data_end > central_directory_offset {
return Err(format!(
"ZIP local data 越界 {}data_end={} central_directory_offset={}",
path.display(),
data_end,
central_directory_offset
));
}
let local_name = read_exact_at(
file,
local_header_offset + LOCAL_FILE_HEADER_FIXED_LEN,
file_name_len as usize,
path,
)?;
if local_name != central_name {
return Err(format!(
"ZIP local header 文件名与 central directory 不一致 {}offset={}",
path.display(),
local_header_offset
));
}
Ok(())
}
fn parse_zip64_extra(
extra: &[u8],
need_uncompressed_size: bool,
need_compressed_size: bool,
need_local_header_offset: bool,
need_disk_start: bool,
path: &Path,
) -> Result<Zip64ExtraValues, String> {
let mut cursor = 0usize;
while cursor + 4 <= extra.len() {
let header_id = read_u16_le(extra, cursor)?;
let data_size = read_u16_le(extra, cursor + 2)? as usize;
cursor += 4;
if cursor + data_size > extra.len() {
return Err(format!("ZIP extra field 边界无效:{}", path.display()));
}
if header_id == ZIP64_EXTENDED_INFORMATION_EXTRA_ID {
let data = &extra[cursor..cursor + data_size];
let values = parse_zip64_extended_information(
data,
need_uncompressed_size,
need_compressed_size,
need_local_header_offset,
need_disk_start,
path,
)?;
return Ok(values);
}
cursor += data_size;
}
if need_uncompressed_size || need_compressed_size || need_local_header_offset || need_disk_start
{
return Err(format!(
"ZIP64 条目缺少 extended information extra{}",
path.display()
));
}
Ok(Zip64ExtraValues::default())
}
fn parse_zip64_extended_information(
data: &[u8],
need_uncompressed_size: bool,
need_compressed_size: bool,
need_local_header_offset: bool,
need_disk_start: bool,
path: &Path,
) -> Result<Zip64ExtraValues, String> {
let mut cursor = 0usize;
let mut values = Zip64ExtraValues::default();
if need_uncompressed_size {
values.uncompressed_size = Some(read_zip64_u64(data, &mut cursor, path)?);
}
if need_compressed_size {
values.compressed_size = Some(read_zip64_u64(data, &mut cursor, path)?);
}
if need_local_header_offset {
values.local_header_offset = Some(read_zip64_u64(data, &mut cursor, path)?);
}
if need_disk_start {
if cursor + 4 > data.len() {
return Err(format!("ZIP64 extra 缺少 disk start{}", path.display()));
}
values.disk_start = Some(read_u32_le(data, cursor)?);
}
Ok(values)
}
fn read_zip64_u64(data: &[u8], cursor: &mut usize, path: &Path) -> Result<u64, String> {
if *cursor + 8 > data.len() {
return Err(format!("ZIP64 extra 字段长度不足:{}", path.display()));
}
let value = read_u64_le(data, *cursor)?;
*cursor += 8;
Ok(value)
}
fn read_exact_at(file: &mut File, offset: u64, len: usize, path: &Path) -> Result<Vec<u8>, String> {
file.seek(SeekFrom::Start(offset)).map_err(|error| {
format!(
"定位 ZIP 文件失败 {}offset={offset}{error}",
path.display()
)
})?;
let mut data = vec![0u8; len];
file.read_exact(&mut data).map_err(|error| {
format!(
"读取 ZIP 文件失败 {}offset={offset} len={len}{error}",
path.display()
)
})?;
Ok(data)
}
fn checked_add(left: u64, right: u64, label: &str, path: &Path) -> Result<u64, String> {
left.checked_add(right)
.ok_or_else(|| format!("{label}{}", path.display()))
}
fn read_u16_le(bytes: &[u8], offset: usize) -> Result<u16, String> {
let end = offset
.checked_add(2)
.ok_or_else(|| "读取 u16 偏移溢出".to_string())?;
let slice = bytes
.get(offset..end)
.ok_or_else(|| "读取 u16 越界".to_string())?;
Ok(u16::from_le_bytes([slice[0], slice[1]]))
}
fn read_u32_le(bytes: &[u8], offset: usize) -> Result<u32, String> {
let end = offset
.checked_add(4)
.ok_or_else(|| "读取 u32 偏移溢出".to_string())?;
let slice = bytes
.get(offset..end)
.ok_or_else(|| "读取 u32 越界".to_string())?;
Ok(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
}
fn read_u64_le(bytes: &[u8], offset: usize) -> Result<u64, String> {
let end = offset
.checked_add(8)
.ok_or_else(|| "读取 u64 偏移溢出".to_string())?;
let slice = bytes
.get(offset..end)
.ok_or_else(|| "读取 u64 越界".to_string())?;
Ok(u64::from_le_bytes([
slice[0], slice[1], slice[2], slice[3], slice[4], slice[5], slice[6], slice[7],
]))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct EocdRecord {
offset: u64,
disk_number: u16,
central_directory_disk: u16,
disk_entry_count: u16,
total_entry_count: u16,
central_directory_size: u32,
central_directory_offset: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct CentralDirectoryDescriptor {
entry_count: u64,
size: u64,
offset: u64,
suffix_offset: u64,
uses_zip64: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct Zip64ExtraValues {
uncompressed_size: Option<u64>,
compressed_size: Option<u64>,
local_header_offset: Option<u64>,
disk_start: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn empty_zip() -> Vec<u8> {
vec![
0x50, 0x4b, 0x05, 0x06, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
]
}
fn one_file_zip(name: &[u8], data: &[u8]) -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&LOCAL_FILE_HEADER_SIGNATURE.to_le_bytes());
bytes.extend_from_slice(&20u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
bytes.extend_from_slice(&(name.len() as u16).to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(name);
bytes.extend_from_slice(data);
let central_offset = bytes.len() as u32;
bytes.extend_from_slice(&CENTRAL_DIRECTORY_SIGNATURE.to_le_bytes());
bytes.extend_from_slice(&20u16.to_le_bytes());
bytes.extend_from_slice(&20u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
bytes.extend_from_slice(&(name.len() as u16).to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(&0u32.to_le_bytes());
bytes.extend_from_slice(name);
let central_size = bytes.len() as u32 - central_offset;
bytes.extend_from_slice(&EOCD_SIGNATURE.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes());
bytes.extend_from_slice(&central_size.to_le_bytes());
bytes.extend_from_slice(&central_offset.to_le_bytes());
bytes.extend_from_slice(&0u16.to_le_bytes());
bytes
}
#[test]
fn accepts_empty_zip() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("empty.zip");
fs::write(&path, empty_zip()).unwrap();
let report = validate_zip_structure(&path).unwrap();
assert_eq!(report.entry_count, 0);
assert!(!report.uses_zip64);
}
#[test]
fn accepts_one_file_zip() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("one.zip");
fs::write(&path, one_file_zip(b"nested/file.txt", b"hello")).unwrap();
let report = validate_zip_structure(&path).unwrap();
assert_eq!(report.entry_count, 1);
assert!(!report.uses_zip64);
}
#[test]
fn rejects_truncated_zip() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("bad.zip");
let mut bytes = one_file_zip(b"file.txt", b"hello");
bytes.truncate(bytes.len() - 8);
fs::write(&path, bytes).unwrap();
let error = validate_zip_structure(&path).unwrap_err();
assert!(error.contains("EOCD") || error.contains("越界"));
}
#[test]
fn rejects_local_name_mismatch() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("bad-name.zip");
let mut bytes = one_file_zip(b"file.txt", b"hello");
let central_name_offset = 30 + b"file.txt".len() + b"hello".len() + 46;
bytes[central_name_offset] = b'X';
fs::write(&path, bytes).unwrap();
let error = validate_zip_structure(&path).unwrap_err();
assert!(error.contains("文件名"));
}
}