use crate::reader::ReadExt;
use crate::{Result, ZiPatchError};
use flate2::read::DeflateDecoder;
use std::borrow::Cow;
use std::io::{Cursor, Read, Write};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqpkFileOperation {
AddFile,
RemoveAll,
DeleteFile,
MakeDirTree,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SqpkCompressedBlock {
is_compressed: bool,
decompressed_size: usize,
data: Vec<u8>,
}
impl SqpkCompressedBlock {
#[must_use]
pub fn new(is_compressed: bool, decompressed_size: usize, data: Vec<u8>) -> Self {
Self {
is_compressed,
decompressed_size,
data,
}
}
fn read<R: Read>(r: &mut R) -> Result<Self> {
let header_size_raw = r.read_i32_le()?;
r.skip(4)?; let compressed_size = r.read_i32_le()?;
let decompressed_size_raw = r.read_i32_le()?;
if header_size_raw < 0 {
return Err(ZiPatchError::InvalidField {
context: "negative header_size in block",
});
}
if decompressed_size_raw < 0 {
return Err(ZiPatchError::InvalidField {
context: "negative decompressed_size in block",
});
}
let is_compressed = compressed_size != 0x7d00;
if is_compressed && compressed_size < 0 {
return Err(ZiPatchError::InvalidField {
context: "negative compressed_size in block",
});
}
let header_size = header_size_raw as usize;
let decompressed_size = decompressed_size_raw as usize;
let data_len = if is_compressed {
compressed_size
} else {
decompressed_size_raw
};
let block_len = ((data_len as u32 + 143) & !127u32) as usize;
let data = if is_compressed {
r.read_exact_vec(block_len - header_size)?
} else {
let d = r.read_exact_vec(decompressed_size)?;
r.skip((block_len - header_size - decompressed_size) as u64)?;
d
};
Ok(SqpkCompressedBlock {
is_compressed,
decompressed_size,
data,
})
}
pub fn decompress_into(&self, w: &mut impl Write) -> Result<()> {
if self.is_compressed {
std::io::copy(&mut DeflateDecoder::new(self.data.as_slice()), w)
.map_err(ZiPatchError::Decompress)?;
} else {
w.write_all(&self.data)?;
}
Ok(())
}
pub fn decompress(&self) -> crate::Result<Cow<'_, [u8]>> {
if self.is_compressed {
let mut out = Vec::with_capacity(self.decompressed_size);
self.decompress_into(&mut out)?;
Ok(Cow::Owned(out))
} else {
Ok(Cow::Borrowed(&self.data))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SqpkFile {
pub operation: SqpkFileOperation,
pub file_offset: i64,
pub file_size: i64,
pub expansion_id: u16,
pub path: String,
pub block_source_offsets: Vec<u64>,
pub blocks: Vec<SqpkCompressedBlock>,
}
pub(crate) fn parse(body: &[u8]) -> Result<SqpkFile> {
let mut c = Cursor::new(body);
let operation = match c.read_u8()? {
b'A' => SqpkFileOperation::AddFile,
b'R' => SqpkFileOperation::RemoveAll,
b'D' => SqpkFileOperation::DeleteFile,
b'M' => SqpkFileOperation::MakeDirTree,
b => {
return Err(ZiPatchError::UnknownFileOperation(b));
}
};
c.skip(2)?;
let file_offset = c.read_u64_be()? as i64;
let file_size = c.read_u64_be()? as i64;
let path_len = c.read_u32_be()?;
let expansion_id = c.read_u16_be()?;
c.skip(2)?;
let path_bytes = c.read_exact_vec(path_len as usize)?;
let path = String::from_utf8(path_bytes)
.map(|s| s.trim_end_matches('\0').to_owned())
.map_err(ZiPatchError::Utf8Error)?;
let (blocks, block_source_offsets) = if matches!(operation, SqpkFileOperation::AddFile) {
let mut blocks = Vec::new();
let mut offsets = Vec::new();
while (c.position() as usize) < body.len() {
offsets.push(c.position() + 16);
blocks.push(SqpkCompressedBlock::read(&mut c)?);
}
(blocks, offsets)
} else {
(Vec::new(), Vec::new())
};
Ok(SqpkFile {
operation,
file_offset,
file_size,
expansion_id,
path,
block_source_offsets,
blocks,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn make_header(
op: u8,
file_offset: i64,
file_size: i64,
path: &[u8],
expansion_id: u16,
) -> Vec<u8> {
let mut body = Vec::new();
body.push(op);
body.extend_from_slice(&[0u8; 2]); body.extend_from_slice(&(file_offset as u64).to_be_bytes());
body.extend_from_slice(&(file_size as u64).to_be_bytes());
body.extend_from_slice(&(path.len() as u32).to_be_bytes());
body.extend_from_slice(&expansion_id.to_be_bytes());
body.extend_from_slice(&[0u8; 2]); body.extend_from_slice(path);
body
}
#[test]
fn parses_add_file_no_blocks() {
let body = make_header(b'A', 0, 512, b"test\0", 1);
let cmd = parse(&body).unwrap();
assert!(matches!(cmd.operation, SqpkFileOperation::AddFile));
assert_eq!(cmd.file_offset, 0);
assert_eq!(cmd.file_size, 512);
assert_eq!(cmd.expansion_id, 1);
assert_eq!(cmd.path, "test");
assert!(cmd.blocks.is_empty());
assert!(cmd.block_source_offsets.is_empty());
}
#[test]
fn parses_add_file_uncompressed_block() {
let mut body = make_header(b'A', 0, 0, b"\0", 0);
body.extend_from_slice(&16i32.to_le_bytes()); body.extend_from_slice(&0u32.to_le_bytes()); body.extend_from_slice(&0x7d00i32.to_le_bytes()); body.extend_from_slice(&8i32.to_le_bytes()); body.extend_from_slice(&[0xABu8; 8]); body.extend_from_slice(&[0u8; 104]);
let cmd = parse(&body).unwrap();
assert_eq!(cmd.blocks.len(), 1);
let block = &cmd.blocks[0];
assert!(!block.is_compressed);
assert_eq!(block.decompressed_size, 8);
assert_eq!(block.data.len(), 8);
assert!(block.data.iter().all(|&b| b == 0xAB));
assert_eq!(block.decompress().unwrap(), vec![0xABu8; 8]);
assert_eq!(cmd.block_source_offsets, vec![44u64]); }
#[test]
fn parses_remove_all_operation() {
let body = make_header(b'R', 0, 0, b"\0", 0);
let cmd = parse(&body).unwrap();
assert!(matches!(cmd.operation, SqpkFileOperation::RemoveAll));
assert!(cmd.blocks.is_empty());
assert!(cmd.block_source_offsets.is_empty());
}
#[test]
fn parses_delete_file_operation() {
let body = make_header(b'D', 0, 0, b"sqpack/foo.dat\0", 0);
let cmd = parse(&body).unwrap();
assert!(matches!(cmd.operation, SqpkFileOperation::DeleteFile));
assert_eq!(cmd.path, "sqpack/foo.dat");
}
#[test]
fn parses_make_dir_tree_operation() {
let body = make_header(b'M', 0, 0, b"sqpack/ex1\0", 0);
let cmd = parse(&body).unwrap();
assert!(matches!(cmd.operation, SqpkFileOperation::MakeDirTree));
assert_eq!(cmd.path, "sqpack/ex1");
}
#[test]
fn rejects_unknown_operation() {
let body = make_header(b'Z', 0, 0, b"\0", 0);
assert!(parse(&body).is_err());
}
fn block_with_sizes(header_size: i32, compressed_size: i32, decompressed_size: i32) -> Vec<u8> {
let mut body = make_header(b'A', 0, 0, b"\0", 0);
body.extend_from_slice(&header_size.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); body.extend_from_slice(&compressed_size.to_le_bytes());
body.extend_from_slice(&decompressed_size.to_le_bytes());
body
}
#[test]
fn rejects_negative_header_size() {
let body = block_with_sizes(-1, 0x7d00, 0);
let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
panic!("expected InvalidField for negative header_size");
};
assert!(
context.contains("header_size"),
"unexpected context: {context}"
);
}
#[test]
fn rejects_negative_decompressed_size() {
let body = block_with_sizes(16, 0x7d00, -1);
let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
panic!("expected InvalidField for negative decompressed_size");
};
assert!(
context.contains("decompressed_size"),
"unexpected context: {context}"
);
}
#[test]
fn rejects_negative_compressed_size() {
let body = block_with_sizes(16, -1, 8);
let Err(ZiPatchError::InvalidField { context }) = parse(&body) else {
panic!("expected InvalidField for negative compressed_size");
};
assert!(
context.contains("compressed_size"),
"unexpected context: {context}"
);
}
#[test]
fn rejects_invalid_utf8_in_path() {
let body = make_header(b'D', 0, 0, &[0xFFu8], 0);
assert!(matches!(parse(&body), Err(ZiPatchError::Utf8Error(_))));
}
#[test]
fn decompress_into_uncompressed_writes_data_verbatim() {
let block = SqpkCompressedBlock::new(false, 5, b"hello".to_vec());
let mut out = Vec::new();
block.decompress_into(&mut out).unwrap();
assert_eq!(out, b"hello");
}
#[test]
fn decompress_returns_borrowed_for_uncompressed() {
let block = SqpkCompressedBlock::new(false, 4, b"data".to_vec());
let cow = block.decompress().unwrap();
assert!(matches!(cow, Cow::Borrowed(_)));
assert_eq!(&*cow, b"data");
}
#[test]
fn decompress_into_compressed_propagates_decompress_error() {
let block = SqpkCompressedBlock::new(true, 16, vec![0xFFu8; 16]);
let mut out = Vec::new();
assert!(matches!(
block.decompress_into(&mut out),
Err(ZiPatchError::Decompress(_))
));
assert!(matches!(
block.decompress(),
Err(ZiPatchError::Decompress(_))
));
}
#[test]
fn parses_compressed_block() {
use flate2::Compression;
use flate2::write::DeflateEncoder;
use std::io::Write;
let raw: &[u8] = b"hello compressed world";
let mut enc = DeflateEncoder::new(Vec::new(), Compression::default());
enc.write_all(raw).unwrap();
let compressed = enc.finish().unwrap();
let header_size: i32 = 16;
let compressed_size = compressed.len() as i32;
let decompressed_size = raw.len() as i32;
let block_len = ((compressed_size as u32 + 143) & !127) as usize;
let trailing_pad = block_len - header_size as usize - compressed.len();
let mut body = make_header(b'A', 0, 0, b"\0", 0);
body.extend_from_slice(&header_size.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes()); body.extend_from_slice(&compressed_size.to_le_bytes());
body.extend_from_slice(&decompressed_size.to_le_bytes());
body.extend_from_slice(&compressed);
body.extend_from_slice(&vec![0u8; trailing_pad]);
let cmd = parse(&body).unwrap();
assert_eq!(cmd.blocks.len(), 1);
let block = &cmd.blocks[0];
assert!(block.is_compressed);
assert_eq!(block.decompressed_size, raw.len());
assert_eq!(block.decompress().unwrap(), raw);
assert_eq!(cmd.block_source_offsets, vec![44u64]); }
}