use oxiarc_deflate::inflate;
use std::fs;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ArchiveError {
#[error("I/O error: {0}")]
Io(String),
#[error("Invalid ZIP format: {0}")]
InvalidFormat(String),
#[error("Unsupported compression method: {method}")]
UnsupportedCompression {
method: u16,
},
#[error("Decompression failed: {0}")]
DecompressionFailed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveEntry {
pub name: String,
pub compressed_size: u64,
pub uncompressed_size: u64,
pub data: Vec<u8>,
}
impl ArchiveEntry {
pub fn new(name: &str, data: Vec<u8>) -> Self {
let len = data.len() as u64;
ArchiveEntry {
name: name.to_string(),
compressed_size: len,
uncompressed_size: len,
data,
}
}
pub fn compression_ratio(&self) -> f64 {
if self.uncompressed_size == 0 {
1.0
} else {
self.compressed_size as f64 / self.uncompressed_size as f64
}
}
}
#[derive(Debug)]
pub struct ArchiveReader {
entries: Vec<ArchiveEntry>,
source: String,
}
impl ArchiveReader {
pub fn new(source: &str) -> Self {
ArchiveReader {
entries: Vec::new(),
source: source.to_string(),
}
}
pub fn load_entry(&mut self, entry: ArchiveEntry) {
self.entries.push(entry);
}
pub fn entry_names(&self) -> Vec<&str> {
self.entries.iter().map(|e| e.name.as_str()).collect()
}
pub fn find_entry(&self, name: &str) -> Option<&ArchiveEntry> {
self.entries.iter().find(|e| e.name == name)
}
pub fn total_uncompressed(&self) -> u64 {
self.entries.iter().map(|e| e.uncompressed_size).sum()
}
pub fn entry_count(&self) -> usize {
self.entries.len()
}
pub fn source(&self) -> &str {
&self.source
}
}
#[inline]
fn read_u16_le(buf: &[u8], offset: usize) -> Result<u16, ArchiveError> {
if offset + 2 > buf.len() {
return Err(ArchiveError::InvalidFormat(format!(
"u16 read at offset {} overflows buffer len {}",
offset,
buf.len()
)));
}
Ok(u16::from_le_bytes([buf[offset], buf[offset + 1]]))
}
#[inline]
fn read_u32_le(buf: &[u8], offset: usize) -> Result<u32, ArchiveError> {
if offset + 4 > buf.len() {
return Err(ArchiveError::InvalidFormat(format!(
"u32 read at offset {} overflows buffer len {}",
offset,
buf.len()
)));
}
Ok(u32::from_le_bytes([
buf[offset],
buf[offset + 1],
buf[offset + 2],
buf[offset + 3],
]))
}
const SIG_LOCAL_FILE_HEADER: [u8; 4] = [0x50, 0x4B, 0x03, 0x04];
const SIG_CENTRAL_DIR_ENTRY: [u8; 4] = [0x50, 0x4B, 0x01, 0x02];
const SIG_EOCD: [u8; 4] = [0x50, 0x4B, 0x05, 0x06];
const COMPRESSION_STORED: u16 = 0;
const COMPRESSION_DEFLATE: u16 = 8;
const EOCD_FIXED_SIZE: usize = 22;
const CD_ENTRY_FIXED_SIZE: usize = 46;
const LFH_FIXED_SIZE: usize = 30;
mod eocd {
pub const TOTAL_ENTRIES: usize = 10;
pub const CD_OFFSET: usize = 16;
pub const COMMENT_LEN: usize = 20;
}
struct EocdRecord {
total_entries: u16,
cd_offset: u32,
}
fn find_eocd(buf: &[u8]) -> Result<EocdRecord, ArchiveError> {
let file_len = buf.len();
if file_len < EOCD_FIXED_SIZE {
return Err(ArchiveError::InvalidFormat(
"file is too small to contain an EOCD record".to_string(),
));
}
let search_start = file_len.saturating_sub(EOCD_FIXED_SIZE + 0xFFFF);
let mut pos = file_len - EOCD_FIXED_SIZE;
loop {
if buf[pos..pos + 4] == SIG_EOCD {
let comment_len = read_u16_le(buf, pos + eocd::COMMENT_LEN)? as usize;
if pos + EOCD_FIXED_SIZE + comment_len == file_len {
let total_entries = read_u16_le(buf, pos + eocd::TOTAL_ENTRIES)?;
let cd_offset = read_u32_le(buf, pos + eocd::CD_OFFSET)?;
return Ok(EocdRecord {
total_entries,
cd_offset,
});
}
}
if pos == search_start {
break;
}
pos -= 1;
}
Err(ArchiveError::InvalidFormat(
"EOCD signature (PK\\x05\\x06) not found".to_string(),
))
}
mod cd {
pub const COMPRESSION_METHOD: usize = 10;
pub const CRC32: usize = 16;
pub const COMPRESSED_SIZE: usize = 20;
pub const UNCOMPRESSED_SIZE: usize = 24;
pub const NAME_LEN: usize = 28;
pub const EXTRA_LEN: usize = 30;
pub const COMMENT_LEN: usize = 32;
pub const LOCAL_HEADER_OFFSET: usize = 42;
pub const NAME_START: usize = 46;
}
struct CdEntry {
compression_method: u16,
#[allow(dead_code)] crc32: u32,
compressed_size: u32,
uncompressed_size: u32,
name: String,
local_header_offset: u32,
record_size: usize,
}
fn parse_cd_entry(buf: &[u8], cd_start: usize) -> Result<CdEntry, ArchiveError> {
if cd_start + CD_ENTRY_FIXED_SIZE > buf.len() {
return Err(ArchiveError::InvalidFormat(format!(
"CD entry at offset {} is truncated (file len {})",
cd_start,
buf.len()
)));
}
if buf[cd_start..cd_start + 4] != SIG_CENTRAL_DIR_ENTRY {
return Err(ArchiveError::InvalidFormat(format!(
"Expected CD entry signature at offset {}, got {:02x?}",
cd_start,
&buf[cd_start..cd_start + 4]
)));
}
let compression_method = read_u16_le(buf, cd_start + cd::COMPRESSION_METHOD)?;
let crc32 = read_u32_le(buf, cd_start + cd::CRC32)?;
let compressed_size = read_u32_le(buf, cd_start + cd::COMPRESSED_SIZE)?;
let uncompressed_size = read_u32_le(buf, cd_start + cd::UNCOMPRESSED_SIZE)?;
let name_len = read_u16_le(buf, cd_start + cd::NAME_LEN)? as usize;
let extra_len = read_u16_le(buf, cd_start + cd::EXTRA_LEN)? as usize;
let comment_len = read_u16_le(buf, cd_start + cd::COMMENT_LEN)? as usize;
let local_header_offset = read_u32_le(buf, cd_start + cd::LOCAL_HEADER_OFFSET)?;
let name_end = cd_start + cd::NAME_START + name_len;
if name_end > buf.len() {
return Err(ArchiveError::InvalidFormat(format!(
"CD entry name extends beyond EOF at offset {}",
cd_start
)));
}
let name = String::from_utf8_lossy(&buf[cd_start + cd::NAME_START..name_end]).into_owned();
let record_size = CD_ENTRY_FIXED_SIZE + name_len + extra_len + comment_len;
Ok(CdEntry {
compression_method,
crc32,
compressed_size,
uncompressed_size,
name,
local_header_offset,
record_size,
})
}
mod lfh {
pub const NAME_LEN: usize = 26;
pub const EXTRA_LEN: usize = 28;
}
fn data_start_from_lfh(buf: &[u8], lfh_start: usize) -> Result<usize, ArchiveError> {
if lfh_start + LFH_FIXED_SIZE > buf.len() {
return Err(ArchiveError::InvalidFormat(format!(
"Local File Header at offset {} is truncated",
lfh_start
)));
}
if buf[lfh_start..lfh_start + 4] != SIG_LOCAL_FILE_HEADER {
return Err(ArchiveError::InvalidFormat(format!(
"Expected Local File Header signature at offset {}, got {:02x?}",
lfh_start,
&buf[lfh_start..lfh_start + 4]
)));
}
let name_len = read_u16_le(buf, lfh_start + lfh::NAME_LEN)? as usize;
let extra_len = read_u16_le(buf, lfh_start + lfh::EXTRA_LEN)? as usize;
Ok(lfh_start + LFH_FIXED_SIZE + name_len + extra_len)
}
fn decompress_entry(
method: u16,
compressed_data: &[u8],
uncompressed_size: u32,
) -> Result<Vec<u8>, ArchiveError> {
match method {
COMPRESSION_STORED => {
if compressed_data.len() != uncompressed_size as usize {
return Err(ArchiveError::InvalidFormat(format!(
"Stored entry: compressed data len {} != declared uncompressed size {}",
compressed_data.len(),
uncompressed_size
)));
}
Ok(compressed_data.to_vec())
}
COMPRESSION_DEFLATE => {
inflate(compressed_data).map_err(|e| ArchiveError::DecompressionFailed(e.to_string()))
}
other => Err(ArchiveError::UnsupportedCompression { method: other }),
}
}
pub fn open_archive(path: &str) -> Result<ArchiveReader, ArchiveError> {
let buf = fs::read(path).map_err(|e| ArchiveError::Io(e.to_string()))?;
parse_zip_bytes(&buf, path)
}
pub fn open_archive_stub(path: &str) -> ArchiveReader {
open_archive(path).unwrap_or_else(|_| ArchiveReader::new(path))
}
fn parse_zip_bytes(buf: &[u8], source: &str) -> Result<ArchiveReader, ArchiveError> {
let eocd = find_eocd(buf)?;
let mut reader = ArchiveReader::new(source);
let total_entries = eocd.total_entries as usize;
let cd_start = eocd.cd_offset as usize;
if cd_start > buf.len() {
return Err(ArchiveError::InvalidFormat(format!(
"Central Directory offset {} is beyond file length {}",
cd_start,
buf.len()
)));
}
let mut cd_cursor = cd_start;
for entry_idx in 0..total_entries {
let cd_entry = parse_cd_entry(buf, cd_cursor)?;
cd_cursor += cd_entry.record_size;
let lfh_start = cd_entry.local_header_offset as usize;
let data_start = data_start_from_lfh(buf, lfh_start)
.map_err(|e| ArchiveError::InvalidFormat(format!("entry {}: {}", entry_idx, e)))?;
let compressed_size = cd_entry.compressed_size as usize;
let data_end = data_start + compressed_size;
if data_end > buf.len() {
return Err(ArchiveError::InvalidFormat(format!(
"Entry '{}': compressed data [{}..{}] extends beyond file length {}",
cd_entry.name,
data_start,
data_end,
buf.len()
)));
}
let compressed_bytes = &buf[data_start..data_end];
let decompressed = decompress_entry(
cd_entry.compression_method,
compressed_bytes,
cd_entry.uncompressed_size,
)?;
reader.load_entry(ArchiveEntry {
name: cd_entry.name,
compressed_size: cd_entry.compressed_size as u64,
uncompressed_size: cd_entry.uncompressed_size as u64,
data: decompressed,
});
}
Ok(reader)
}
pub fn read_entry_bytes(reader: &ArchiveReader, name: &str) -> Option<Vec<u8>> {
reader.find_entry(name).map(|e| e.data.clone())
}
pub fn read_entry_text(reader: &ArchiveReader, name: &str) -> Option<String> {
let bytes = read_entry_bytes(reader, name)?;
String::from_utf8(bytes).ok()
}
pub fn list_matching<'a>(reader: &'a ArchiveReader, pattern: &str) -> Vec<&'a str> {
reader
.entries
.iter()
.filter(|e| e.name.contains(pattern))
.map(|e| e.name.as_str())
.collect()
}
pub fn total_compressed(reader: &ArchiveReader) -> u64 {
reader.entries.iter().map(|e| e.compressed_size).sum()
}
#[cfg(test)]
mod zip_builder {
pub struct ZipFile {
local_sections: Vec<u8>,
central_dirs: Vec<u8>,
entry_count: u16,
}
impl ZipFile {
pub fn new() -> Self {
ZipFile {
local_sections: Vec::new(),
central_dirs: Vec::new(),
entry_count: 0,
}
}
pub fn add_stored(&mut self, name: &str, data: &[u8]) {
self.add_entry(0, name, data, data);
}
pub fn add_entry(&mut self, method: u16, name: &str, compressed: &[u8], original: &[u8]) {
let name_bytes = name.as_bytes();
let local_offset = self.local_sections.len() as u32;
let crc = crc32_simple(original);
let comp_size = compressed.len() as u32;
let uncomp_size = original.len() as u32;
self.local_sections
.extend_from_slice(&[0x50, 0x4B, 0x03, 0x04]); self.local_sections.extend_from_slice(&[0x14, 0x00]); self.local_sections.extend_from_slice(&[0x00, 0x00]); self.local_sections.extend_from_slice(&method.to_le_bytes()); self.local_sections.extend_from_slice(&[0x00, 0x00]); self.local_sections.extend_from_slice(&[0x00, 0x00]); self.local_sections.extend_from_slice(&crc.to_le_bytes()); self.local_sections
.extend_from_slice(&comp_size.to_le_bytes()); self.local_sections
.extend_from_slice(&uncomp_size.to_le_bytes()); self.local_sections
.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes()); self.local_sections.extend_from_slice(&[0x00, 0x00]); self.local_sections.extend_from_slice(name_bytes); self.local_sections.extend_from_slice(compressed);
self.central_dirs
.extend_from_slice(&[0x50, 0x4B, 0x01, 0x02]); self.central_dirs.extend_from_slice(&[0x1E, 0x03]); self.central_dirs.extend_from_slice(&[0x14, 0x00]); self.central_dirs.extend_from_slice(&[0x00, 0x00]); self.central_dirs.extend_from_slice(&method.to_le_bytes()); self.central_dirs.extend_from_slice(&[0x00, 0x00]); self.central_dirs.extend_from_slice(&[0x00, 0x00]); self.central_dirs.extend_from_slice(&crc.to_le_bytes()); self.central_dirs
.extend_from_slice(&comp_size.to_le_bytes()); self.central_dirs
.extend_from_slice(&uncomp_size.to_le_bytes()); self.central_dirs
.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes()); self.central_dirs.extend_from_slice(&[0x00, 0x00]); self.central_dirs.extend_from_slice(&[0x00, 0x00]); self.central_dirs.extend_from_slice(&[0x00, 0x00]); self.central_dirs.extend_from_slice(&[0x00, 0x00]); self.central_dirs
.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); self.central_dirs
.extend_from_slice(&local_offset.to_le_bytes()); self.central_dirs.extend_from_slice(name_bytes);
self.entry_count += 1;
}
pub fn finish(self) -> Vec<u8> {
let mut out = self.local_sections;
let cd_offset = out.len() as u32;
let cd_size = self.central_dirs.len() as u32;
out.extend_from_slice(&self.central_dirs);
out.extend_from_slice(&[0x50, 0x4B, 0x05, 0x06]); out.extend_from_slice(&[0x00, 0x00]); out.extend_from_slice(&[0x00, 0x00]); out.extend_from_slice(&self.entry_count.to_le_bytes()); out.extend_from_slice(&self.entry_count.to_le_bytes()); out.extend_from_slice(&cd_size.to_le_bytes()); out.extend_from_slice(&cd_offset.to_le_bytes()); out.extend_from_slice(&[0x00, 0x00]); out
}
}
pub fn crc32_simple(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= u32::from(byte);
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xEDB8_8320;
} else {
crc >>= 1;
}
}
}
!crc
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::archive_reader::zip_builder::ZipFile;
use oxiarc_deflate::deflate as oxiarc_deflate_compress;
use std::io::Write;
#[test]
fn test_open_archive_stub() {
let r = open_archive_stub("/tmp/oxihuman_nonexistent_test_stub_file_99999.zip");
assert_eq!(r.entry_count(), 0);
assert_eq!(
r.source(),
"/tmp/oxihuman_nonexistent_test_stub_file_99999.zip"
);
}
#[test]
fn test_load_and_find_entry() {
let mut r = open_archive_stub("/tmp/a.zip");
r.load_entry(ArchiveEntry::new("hello.txt", b"hello".to_vec()));
assert!(r.find_entry("hello.txt").is_some());
assert!(r.find_entry("nope.txt").is_none());
}
#[test]
fn test_read_entry_bytes() {
let mut r = open_archive_stub("/tmp/x.zip");
r.load_entry(ArchiveEntry::new("data.bin", vec![1, 2, 3]));
let bytes = read_entry_bytes(&r, "data.bin").expect("should succeed");
assert_eq!(bytes, vec![1, 2, 3]);
}
#[test]
fn test_read_entry_text() {
let mut r = open_archive_stub("/tmp/x.zip");
r.load_entry(ArchiveEntry::new("note.txt", b"hello world".to_vec()));
let text = read_entry_text(&r, "note.txt").expect("should succeed");
assert_eq!(text, "hello world");
}
#[test]
fn test_total_uncompressed() {
let mut r = open_archive_stub("/tmp/x.zip");
r.load_entry(ArchiveEntry::new("a", vec![0u8; 100]));
r.load_entry(ArchiveEntry::new("b", vec![0u8; 200]));
assert_eq!(r.total_uncompressed(), 300);
}
#[test]
fn test_list_matching() {
let mut r = open_archive_stub("/tmp/x.zip");
r.load_entry(ArchiveEntry::new("src/main.rs", vec![]));
r.load_entry(ArchiveEntry::new("tests/test.rs", vec![]));
let found = list_matching(&r, "src/");
assert_eq!(found.len(), 1);
}
#[test]
fn test_compression_ratio_empty() {
let e = ArchiveEntry::new("empty", vec![]);
assert!((e.compression_ratio() - 1.0).abs() < 0.01);
}
#[test]
fn test_entry_names() {
let mut r = open_archive_stub("/tmp/x.zip");
r.load_entry(ArchiveEntry::new("a.txt", vec![]));
r.load_entry(ArchiveEntry::new("b.txt", vec![]));
let names = r.entry_names();
assert!(names.contains(&"a.txt"));
assert!(names.contains(&"b.txt"));
}
#[test]
fn test_total_compressed() {
let mut r = open_archive_stub("/tmp/x.zip");
r.load_entry(ArchiveEntry::new("f", vec![0u8; 50]));
assert_eq!(total_compressed(&r), 50);
}
#[test]
fn test_entry_count() {
let mut r = open_archive_stub("/tmp/x.zip");
for i in 0..5 {
r.load_entry(ArchiveEntry::new(&format!("f{}.txt", i), vec![]));
}
assert_eq!(r.entry_count(), 5);
}
fn temp_zip_path(suffix: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("oxihuman_test_{}.zip", suffix));
p
}
fn write_zip_to_temp(bytes: Vec<u8>, suffix: &str) -> std::path::PathBuf {
let path = temp_zip_path(suffix);
let mut f = std::fs::File::create(&path).expect("create temp zip");
f.write_all(&bytes).expect("write temp zip");
path
}
#[test]
fn test_open_archive_real_zip() {
let mut builder = ZipFile::new();
builder.add_stored("readme.txt", b"Hello from OxiHuman");
builder.add_stored("data/config.toml", b"[section]\nkey = \"value\"");
let zip_bytes = builder.finish();
let path = write_zip_to_temp(zip_bytes, "real_zip");
let reader = open_archive(path.to_str().expect("valid path"))
.expect("open_archive should succeed on well-formed ZIP");
assert_eq!(reader.entry_count(), 2);
let text = read_entry_text(&reader, "readme.txt").expect("readme.txt should be readable");
assert_eq!(text, "Hello from OxiHuman");
let text2 =
read_entry_text(&reader, "data/config.toml").expect("config.toml should be readable");
assert_eq!(text2, "[section]\nkey = \"value\"");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_open_archive_stored() {
let payload: Vec<u8> = (0u8..=255).collect();
let mut builder = ZipFile::new();
builder.add_stored("binary.bin", &payload);
let zip_bytes = builder.finish();
let path = write_zip_to_temp(zip_bytes, "stored");
let reader = open_archive(path.to_str().expect("valid path")).expect("open_archive stored");
assert_eq!(reader.entry_count(), 1);
let data = read_entry_bytes(&reader, "binary.bin").expect("binary.bin");
assert_eq!(data, payload);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_open_archive_deflated() {
let original: Vec<u8> = (0u8..64).cycle().take(512).collect();
let compressed = oxiarc_deflate_compress(&original, 6).expect("deflate test data");
let mut builder = ZipFile::new();
builder.add_entry(8, "deflated.bin", &compressed, &original);
let zip_bytes = builder.finish();
let path = write_zip_to_temp(zip_bytes, "deflated");
let reader =
open_archive(path.to_str().expect("valid path")).expect("open_archive deflated");
assert_eq!(reader.entry_count(), 1);
let data = read_entry_bytes(&reader, "deflated.bin").expect("deflated.bin");
assert_eq!(data, original, "decompressed data must match original");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_open_archive_not_found_returns_error() {
let result = open_archive("/tmp/oxihuman_definitely_does_not_exist_99999.zip");
assert!(
matches!(result, Err(ArchiveError::Io(_))),
"expected Err(ArchiveError::Io), got {:?}",
result
);
}
#[test]
fn test_open_archive_invalid_zip_returns_error() {
let path = {
let mut p = std::env::temp_dir();
p.push("oxihuman_test_invalid_zip.bin");
p
};
std::fs::write(
&path,
b"This is not a ZIP file at all, no signatures here!!",
)
.expect("write invalid file");
let result = open_archive(path.to_str().expect("valid path"));
assert!(
matches!(result, Err(ArchiveError::InvalidFormat(_))),
"expected Err(ArchiveError::InvalidFormat), got {:?}",
result
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_stub_falls_back_on_missing_file() {
let r = open_archive_stub("/tmp/oxihuman_stub_fallback_test_nope.zip");
assert_eq!(
r.entry_count(),
0,
"stub must return empty reader on missing file"
);
}
#[test]
fn test_stub_reads_real_zip_when_present() {
let mut builder = ZipFile::new();
builder.add_stored("hello.txt", b"stub test");
let zip_bytes = builder.finish();
let path = write_zip_to_temp(zip_bytes, "stub_real");
let reader = open_archive_stub(path.to_str().expect("valid path"));
assert_eq!(reader.entry_count(), 1);
let text = read_entry_text(&reader, "hello.txt").expect("hello.txt");
assert_eq!(text, "stub test");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_open_archive_multi_entry() {
let entries: Vec<(&str, Vec<u8>)> = vec![
("a.txt", b"aaa".to_vec()),
("b.txt", b"bbbbb".to_vec()),
("c/d.txt", b"nested file".to_vec()),
("empty.txt", vec![]),
("binary.bin", vec![0xDE, 0xAD, 0xBE, 0xEF]),
];
let mut builder = ZipFile::new();
for (name, data) in &entries {
builder.add_stored(name, data);
}
let zip_bytes = builder.finish();
let path = write_zip_to_temp(zip_bytes, "multi_entry");
let reader =
open_archive(path.to_str().expect("valid path")).expect("open_archive multi_entry");
assert_eq!(reader.entry_count(), entries.len());
for (name, expected) in &entries {
let got = read_entry_bytes(&reader, name)
.unwrap_or_else(|| panic!("entry '{}' not found", name));
assert_eq!(&got, expected, "content mismatch for '{}'", name);
}
let _ = std::fs::remove_file(&path);
}
}