pub(crate) mod adir;
pub(crate) mod afsp;
pub(crate) mod aply;
pub(crate) mod ddir;
pub(crate) mod fhdr;
pub(crate) mod sqpk;
pub(crate) mod util;
pub use adir::AddDirectory;
pub use afsp::ApplyFreeSpace;
pub use aply::{ApplyOption, ApplyOptionKind};
pub use ddir::DeleteDirectory;
pub use fhdr::{FileHeader, FileHeaderV2, FileHeaderV3};
pub use sqpk::{
IndexCommand, SqpackFileId, SqpkAddData, SqpkCommand, SqpkCompressedBlock, SqpkDeleteData,
SqpkExpandData, SqpkFile, SqpkFileOperation, SqpkHeader, SqpkHeaderTarget, SqpkIndex,
SqpkPatchInfo, SqpkTargetInfo, TargetFileKind, TargetHeaderKind,
};
use crate::newtypes::ChunkTag;
use crate::{ParseError, ParseResult as Result};
use std::io::Read;
use tracing::trace;
const MAGIC: [u8; 12] = [
0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A,
];
pub const DEFAULT_MAX_CHUNK_SIZE: u32 = 512 * 1024 * 1024;
#[derive(Debug)]
pub enum Chunk {
FileHeader(FileHeader),
ApplyOption(ApplyOption),
ApplyFreeSpace(ApplyFreeSpace),
AddDirectory(AddDirectory),
DeleteDirectory(DeleteDirectory),
Sqpk(SqpkCommand),
EndOfFile,
}
pub(crate) struct ParsedChunk {
pub(crate) chunk: Chunk,
pub(crate) tag: ChunkTag,
pub(crate) consumed: u64,
}
pub(crate) fn parse_chunk<R: std::io::Read>(
r: &mut R,
verify_checksums: bool,
max_chunk_size: u32,
) -> Result<ParsedChunk> {
let mut size_buf = [0u8; 4];
let size = match r.read_exact(&mut size_buf) {
Ok(()) => u32::from_be_bytes(size_buf) as usize,
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Err(ParseError::TruncatedPatch);
}
Err(e) => return Err(e.into()),
};
if size > max_chunk_size as usize {
return Err(ParseError::OversizedChunk(size));
}
let mut tag = [0u8; 4];
r.read_exact(&mut tag)?;
let mut prefix = [0u8; 5];
let prefix_len = size.min(5);
if prefix_len > 0 {
r.read_exact(&mut prefix[..prefix_len])?;
}
if &tag == b"SQPK" && size >= 5 + SQPK_ADDDATA_HEADER_SIZE && prefix[4] == b'A' {
return parse_sqpk_add_data_fast(r, tag, prefix, size, verify_checksums);
}
let mut body_vec = vec![0u8; size];
body_vec[..prefix_len].copy_from_slice(&prefix[..prefix_len]);
if size > prefix_len {
r.read_exact(&mut body_vec[prefix_len..])?;
}
let mut crc_buf = [0u8; 4];
r.read_exact(&mut crc_buf)?;
let expected_crc = u32::from_be_bytes(crc_buf);
if verify_checksums {
let mut hasher = crc32fast::Hasher::new();
hasher.update(&tag);
hasher.update(&body_vec);
let actual_crc = hasher.finalize();
if actual_crc != expected_crc {
return Err(ParseError::ChecksumMismatch {
tag: ChunkTag::new(tag),
expected: expected_crc,
actual: actual_crc,
});
}
}
trace!(tag = %String::from_utf8_lossy(&tag), "chunk");
let consumed = (size as u64) + 12;
let body = &body_vec[..];
let chunk = match &tag {
b"EOF_" => Chunk::EndOfFile,
b"FHDR" => Chunk::FileHeader(fhdr::parse(body)?),
b"APLY" => Chunk::ApplyOption(aply::parse(body)?),
b"APFS" => Chunk::ApplyFreeSpace(afsp::parse(body)?),
b"ADIR" => Chunk::AddDirectory(adir::parse(body)?),
b"DELD" => Chunk::DeleteDirectory(ddir::parse(body)?),
b"SQPK" => Chunk::Sqpk(sqpk::parse_sqpk(body)?),
_ => return Err(ParseError::UnknownChunkTag(ChunkTag::new(tag))),
};
Ok(ParsedChunk {
chunk,
tag: ChunkTag::new(tag),
consumed,
})
}
const SQPK_ADDDATA_HEADER_SIZE: usize = 23;
fn parse_sqpk_add_data_fast<R: std::io::Read>(
r: &mut R,
tag: [u8; 4],
prefix: [u8; 5],
size: usize,
verify_checksums: bool,
) -> Result<ParsedChunk> {
let inner_size = i32::from_be_bytes([prefix[0], prefix[1], prefix[2], prefix[3]]) as usize;
if inner_size != size {
return Err(ParseError::InvalidField {
context: "SQPK inner size mismatch",
});
}
let mut header = [0u8; SQPK_ADDDATA_HEADER_SIZE];
r.read_exact(&mut header)?;
let main_id = u16::from_be_bytes([header[3], header[4]]);
let sub_id = u16::from_be_bytes([header[5], header[6]]);
let file_id = u32::from_be_bytes([header[7], header[8], header[9], header[10]]);
let block_offset_raw = u32::from_be_bytes([header[11], header[12], header[13], header[14]]);
let data_bytes_raw = u32::from_be_bytes([header[15], header[16], header[17], header[18]]);
let block_delete_raw = u32::from_be_bytes([header[19], header[20], header[21], header[22]]);
let block_offset = (block_offset_raw as u64) << 7;
let data_bytes = (data_bytes_raw as u64) << 7;
let block_delete_number = (block_delete_raw as u64) << 7;
let expected_data = size - 5 - SQPK_ADDDATA_HEADER_SIZE;
if data_bytes as usize != expected_data {
return Err(ParseError::InvalidField {
context: "SqpkAddData data_bytes does not match SQPK body length",
});
}
let mut data = vec![0u8; data_bytes as usize];
r.read_exact(&mut data)?;
let mut crc_buf = [0u8; 4];
r.read_exact(&mut crc_buf)?;
let expected_crc = u32::from_be_bytes(crc_buf);
if verify_checksums {
let mut hasher = crc32fast::Hasher::new();
hasher.update(&tag);
hasher.update(&prefix);
hasher.update(&header);
hasher.update(&data);
let actual_crc = hasher.finalize();
if actual_crc != expected_crc {
return Err(ParseError::ChecksumMismatch {
tag: ChunkTag::new(tag),
expected: expected_crc,
actual: actual_crc,
});
}
}
trace!(tag = %String::from_utf8_lossy(&tag), "chunk");
let chunk = Chunk::Sqpk(sqpk::SqpkCommand::AddData(Box::new(sqpk::SqpkAddData {
target_file: sqpk::SqpackFileId {
main_id,
sub_id,
file_id,
},
block_offset,
data_bytes,
block_delete_number,
data,
})));
let consumed = (size as u64) + 12;
Ok(ParsedChunk {
chunk,
tag: ChunkTag::new(tag),
consumed,
})
}
#[non_exhaustive]
#[derive(Debug)]
pub struct ChunkRecord {
pub chunk: Chunk,
pub tag: ChunkTag,
pub body_offset: u64,
pub bytes_read: u64,
}
#[derive(Debug)]
pub struct ZiPatchReader<R> {
inner: std::io::BufReader<R>,
done: bool,
verify_checksums: bool,
eof_seen: bool,
pub(crate) bytes_read: u64,
patch_name: Option<String>,
max_chunk_size: u32,
}
impl<R: std::io::Read> ZiPatchReader<R> {
pub fn new(reader: R) -> Result<Self> {
let mut reader = std::io::BufReader::new(reader);
let mut magic = [0u8; 12];
reader.read_exact(&mut magic)?;
if magic != MAGIC {
return Err(ParseError::InvalidMagic);
}
Ok(Self {
inner: reader,
done: false,
verify_checksums: true,
eof_seen: false,
bytes_read: 12,
patch_name: None,
max_chunk_size: DEFAULT_MAX_CHUNK_SIZE,
})
}
#[must_use]
pub fn with_max_chunk_size(mut self, bytes: u32) -> Self {
assert!(bytes > 0, "with_max_chunk_size(0) is invalid");
self.max_chunk_size = bytes;
self
}
#[must_use]
pub fn max_chunk_size(&self) -> u32 {
self.max_chunk_size
}
#[must_use]
pub fn with_patch_name(mut self, name: impl Into<String>) -> Self {
self.patch_name = Some(name.into());
self
}
#[must_use]
pub fn patch_name(&self) -> Option<&str> {
self.patch_name.as_deref()
}
pub(crate) fn inner_mut(&mut self) -> &mut std::io::BufReader<R> {
&mut self.inner
}
#[must_use]
pub fn with_checksum_verification(mut self, on: bool) -> Self {
self.verify_checksums = on;
self
}
pub fn is_complete(&self) -> bool {
self.eof_seen
}
#[must_use]
pub fn bytes_read(&self) -> u64 {
self.bytes_read
}
pub fn next_chunk(&mut self) -> Result<Option<ChunkRecord>> {
if self.done {
return Ok(None);
}
let body_offset = self.bytes_read + 8;
match parse_chunk(&mut self.inner, self.verify_checksums, self.max_chunk_size) {
Ok(ParsedChunk {
chunk: Chunk::EndOfFile,
consumed,
..
}) => {
self.bytes_read += consumed;
self.done = true;
self.eof_seen = true;
Ok(None)
}
Ok(ParsedChunk {
chunk,
tag,
consumed,
}) => {
self.bytes_read += consumed;
Ok(Some(ChunkRecord {
chunk,
tag,
body_offset,
bytes_read: self.bytes_read,
}))
}
Err(e) => {
self.done = true;
Err(e)
}
}
}
}
pub fn open_patch(
path: impl AsRef<std::path::Path>,
) -> crate::ParseResult<ZiPatchReader<impl std::io::Read + 'static>> {
let file = std::fs::File::open(path)?;
ZiPatchReader::new(file)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::make_chunk;
use std::io::Cursor;
#[test]
fn truncated_at_chunk_boundary_yields_truncated_patch() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
match reader.next_chunk() {
Err(ParseError::TruncatedPatch) => {}
other => panic!("expected TruncatedPatch, got {other:?}"),
}
assert!(!reader.is_complete(), "stream is not clean-ended");
}
#[test]
fn non_eof_io_error_on_body_len_read_propagates_as_io() {
struct BrokenReader;
impl std::io::Read for BrokenReader {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"simulated broken pipe",
))
}
}
let result = parse_chunk(&mut BrokenReader, false, DEFAULT_MAX_CHUNK_SIZE);
match result {
Err(ParseError::Io { source: e }) => {
assert_eq!(
e.kind(),
std::io::ErrorKind::BrokenPipe,
"non-EOF I/O error must propagate unchanged, got kind {:?}",
e.kind()
);
}
Err(other) => panic!("expected ParseError::Io(BrokenPipe), got {other:?}"),
Ok(_) => panic!("expected an error, got Ok"),
}
}
#[test]
fn truncated_after_one_chunk_yields_truncated_patch() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&4u32.to_be_bytes());
adir_body.extend_from_slice(b"test");
let chunk = make_chunk(b"ADIR", &adir_body);
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&chunk);
let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
let first = reader.next_chunk();
assert!(
matches!(first, Ok(Some(_))),
"first ADIR chunk should parse cleanly: {first:?}"
);
match reader.next_chunk() {
Err(ParseError::TruncatedPatch) => {}
other => panic!("expected TruncatedPatch on truncated stream, got {other:?}"),
}
assert!(
!reader.is_complete(),
"is_complete must be false after truncation"
);
}
#[test]
fn checksum_mismatch_returns_checksum_mismatch_error() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&4u32.to_be_bytes());
adir_body.extend_from_slice(b"test");
let mut chunk = make_chunk(b"ADIR", &adir_body);
let last = chunk.len() - 1;
chunk[last] ^= 0xFF;
let mut cur = Cursor::new(chunk);
let result = parse_chunk(&mut cur, true, DEFAULT_MAX_CHUNK_SIZE);
assert!(
matches!(result, Err(ParseError::ChecksumMismatch { .. })),
"corrupted CRC must yield ChecksumMismatch"
);
}
#[test]
fn unknown_chunk_tag_returns_unknown_chunk_tag_error() {
let chunk = make_chunk(b"ZZZZ", &[]);
let mut cur = Cursor::new(chunk);
match parse_chunk(&mut cur, false, DEFAULT_MAX_CHUNK_SIZE) {
Err(ParseError::UnknownChunkTag(tag)) => {
assert_eq!(
tag,
ChunkTag::new(*b"ZZZZ"),
"tag bytes must be preserved in error"
);
}
Err(other) => panic!("expected UnknownChunkTag, got {other:?}"),
Ok(_) => panic!("expected UnknownChunkTag, got Ok"),
}
}
#[test]
fn default_max_chunk_size_matches_constant() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
assert_eq!(reader.max_chunk_size(), DEFAULT_MAX_CHUNK_SIZE);
}
#[test]
fn with_max_chunk_size_overrides_default() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let reader = ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.with_max_chunk_size(4096);
assert_eq!(reader.max_chunk_size(), 4096);
}
#[test]
#[should_panic(expected = "with_max_chunk_size(0) is invalid")]
fn with_max_chunk_size_zero_panics() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let _ = ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.with_max_chunk_size(0);
}
#[test]
fn custom_max_chunk_size_rejects_chunks_above_threshold() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&5u32.to_be_bytes());
adir_body.extend_from_slice(b"hello");
let chunk = make_chunk(b"ADIR", &adir_body);
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&chunk);
let mut reader = ZiPatchReader::new(Cursor::new(patch))
.unwrap()
.with_max_chunk_size(4);
match reader.next_chunk() {
Err(ParseError::OversizedChunk(size)) => assert_eq!(size, 9),
other => panic!("expected OversizedChunk(9), got {other:?}"),
}
}
#[test]
fn oversized_chunk_body_len_returns_oversized_chunk_error() {
let bytes = [0xFFu8, 0xFF, 0xFF, 0xFF];
let mut cur = Cursor::new(&bytes[..]);
let Err(ParseError::OversizedChunk(size)) =
parse_chunk(&mut cur, false, DEFAULT_MAX_CHUNK_SIZE)
else {
panic!("expected OversizedChunk for u32::MAX body_len")
};
assert!(
size > DEFAULT_MAX_CHUNK_SIZE as usize,
"reported size {size} must exceed DEFAULT_MAX_CHUNK_SIZE {DEFAULT_MAX_CHUNK_SIZE}"
);
}
#[test]
fn bytes_read_starts_at_12_before_first_chunk() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
assert_eq!(
reader.bytes_read(),
12,
"bytes_read must be 12 (magic only) before iteration starts"
);
}
#[test]
fn record_carries_tag_body_offset_and_bytes_read() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&1u32.to_be_bytes());
adir_body.extend_from_slice(b"a");
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
assert_eq!(reader.bytes_read(), 12, "pre-read: magic only");
let rec = reader.next_chunk().unwrap().expect("first ADIR record");
assert!(
matches!(rec.chunk, Chunk::AddDirectory(_)),
"first chunk must be ADIR"
);
assert_eq!(rec.tag, ChunkTag::ADIR);
assert_eq!(rec.body_offset, 20);
assert_eq!(rec.bytes_read, 12 + 17, "magic + ADIR frame");
assert!(
reader.next_chunk().unwrap().is_none(),
"EOF_ must terminate iteration"
);
assert_eq!(
reader.bytes_read(),
12 + 17 + 12,
"after EOF_: magic + ADIR + EOF_ frames"
);
assert!(reader.is_complete(), "is_complete must be true after EOF_");
}
#[test]
fn bytes_read_is_monotonically_non_decreasing() {
let make_adir = |name: &[u8]| -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&(name.len() as u32).to_be_bytes());
body.extend_from_slice(name);
make_chunk(b"ADIR", &body)
};
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_adir(b"a"));
patch.extend_from_slice(&make_adir(b"bb"));
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
let mut prev = reader.bytes_read();
while let Some(rec) = reader.next_chunk().unwrap() {
let current = rec.bytes_read;
assert_eq!(
current,
reader.bytes_read(),
"record's bytes_read must equal reader's running counter"
);
assert!(
current > prev,
"non-empty ADIR frame must strictly advance bytes_read: \
{prev} -> {current}"
);
prev = current;
}
assert!(
reader.bytes_read() > prev,
"consuming EOF_ must advance bytes_read by its 12-byte frame: \
{prev} -> {}",
reader.bytes_read()
);
}
#[test]
fn open_patch_opens_minimal_patch_and_reaches_eof() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&MAGIC);
bytes.extend_from_slice(&make_chunk(b"EOF_", &[]));
let tmp = tempfile::tempdir().unwrap();
let file_path = tmp.path().join("test.patch");
std::fs::write(&file_path, &bytes).unwrap();
let mut reader = open_patch(&file_path).expect("open_patch must open valid patch");
assert!(
reader.next_chunk().unwrap().is_none(),
"EOF_ must terminate iteration immediately"
);
assert!(reader.is_complete(), "is_complete must be true after EOF_");
}
#[test]
fn open_patch_returns_io_error_when_file_is_missing() {
let tmp = tempfile::tempdir().unwrap();
let file_path = tmp.path().join("nonexistent.patch");
assert!(
matches!(open_patch(&file_path), Err(ParseError::Io { .. })),
"open_patch on a missing file must return ParseError::Io"
);
}
#[test]
fn reader_is_fused_after_error() {
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"ZZZZ", &[]));
let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
let first = reader.next_chunk();
assert!(
matches!(first, Err(ParseError::UnknownChunkTag(_))),
"first call must yield the error: {first:?}"
);
assert!(
matches!(reader.next_chunk(), Ok(None)),
"fused: must return Ok(None) after error"
);
assert!(
matches!(reader.next_chunk(), Ok(None)),
"fused: still Ok(None) on third call"
);
}
#[test]
fn is_complete_false_until_eof_seen() {
let mut adir_body = Vec::new();
adir_body.extend_from_slice(&1u32.to_be_bytes());
adir_body.extend_from_slice(b"x");
let mut patch = Vec::new();
patch.extend_from_slice(&MAGIC);
patch.extend_from_slice(&make_chunk(b"ADIR", &adir_body));
patch.extend_from_slice(&make_chunk(b"EOF_", &[]));
let mut reader = ZiPatchReader::new(Cursor::new(patch)).unwrap();
assert!(
!reader.is_complete(),
"not complete before reading anything"
);
reader.next_chunk().unwrap().unwrap(); assert!(
!reader.is_complete(),
"not complete after ADIR, before EOF_"
);
assert!(reader.next_chunk().unwrap().is_none(), "EOF_ consumed");
assert!(reader.is_complete(), "complete after EOF_ consumed");
}
}