use std::io::{Read, Seek, SeekFrom};
use super::ModelConfigError;
const EOCD_MAGIC: [u8; 4] = [0x50, 0x4b, 0x05, 0x06];
const LOCATOR_MAGIC: [u8; 4] = [0x50, 0x4b, 0x06, 0x07];
const EOCD64_MAGIC: [u8; 4] = [0x50, 0x4b, 0x06, 0x06];
const CDFH_MAGIC: [u8; 4] = [0x50, 0x4b, 0x01, 0x02];
const EOCD_LEN: usize = 22;
const EOCD_SEARCH: u64 = EOCD_LEN as u64 + 65535;
const LOCATOR_LEN: u64 = 20;
const EOCD64_LEN: u64 = 56;
const MAX_TEXT_ENTRY: u64 = 1 << 20;
pub(crate) struct Trailer {
pub payload_len: u64,
pub entries: Vec<(String, String)>,
}
fn u16_at(b: &[u8], at: usize) -> Option<u64> {
Some(u64::from(u16::from_le_bytes(
b.get(at..at + 2)?.try_into().ok()?,
)))
}
fn u32_at(b: &[u8], at: usize) -> Option<u64> {
Some(u64::from(u32::from_le_bytes(
b.get(at..at + 4)?.try_into().ok()?,
)))
}
fn u64_at(b: &[u8], at: usize) -> Option<u64> {
Some(u64::from_le_bytes(b.get(at..at + 8)?.try_into().ok()?))
}
fn archive_start<R: Read + Seek>(r: &mut R, len: u64, tail: &[u8]) -> Option<u64> {
let tail_start = len - tail.len() as u64;
let at = (0..=tail.len().checked_sub(EOCD_LEN)?).rev().find(|&i| {
tail[i..i + 4] == EOCD_MAGIC
&& u16_at(tail, i + 20).map(|c| i as u64 + EOCD_LEN as u64 + c)
== Some(tail.len() as u64)
})?;
if u16_at(tail, at + 4)? != 0 || u16_at(tail, at + 6)? != 0 {
return None;
}
let eocd = tail_start + at as u64;
let (cd_size, cd_offset) = (u32_at(tail, at + 12)?, u32_at(tail, at + 16)?);
let (cd_end, cd_size, cd_offset) =
if cd_offset == u64::from(u32::MAX) || cd_size == u64::from(u32::MAX) {
let locator = eocd.checked_sub(LOCATOR_LEN)?;
let record = locator.checked_sub(EOCD64_LEN)?;
let mut b = [0u8; (EOCD64_LEN + LOCATOR_LEN) as usize];
r.seek(SeekFrom::Start(record)).ok()?;
r.read_exact(&mut b).ok()?;
let (rec, loc) = b.split_at(EOCD64_LEN as usize);
if loc[..4] != LOCATOR_MAGIC || rec[..4] != EOCD64_MAGIC {
return None;
}
(record, u64_at(rec, 40)?, u64_at(rec, 48)?)
} else {
(eocd, cd_size, cd_offset)
};
let cd_start = cd_end.checked_sub(cd_size)?;
let start = cd_start.checked_sub(cd_offset)?;
let mut magic = [0u8; 4];
r.seek(SeekFrom::Start(cd_start)).ok()?;
r.read_exact(&mut magic).ok()?;
(magic == CDFH_MAGIC).then_some(start)
}
struct Window<R> {
inner: R,
start: u64,
len: u64,
pos: u64,
}
impl<R: Read + Seek> Read for Window<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let left = self.len.saturating_sub(self.pos);
let n = buf.len().min(usize::try_from(left).unwrap_or(usize::MAX));
self.inner.seek(SeekFrom::Start(self.start + self.pos))?;
let n = self.inner.read(&mut buf[..n])?;
self.pos += n as u64;
Ok(n)
}
}
impl<R: Read + Seek> Seek for Window<R> {
fn seek(&mut self, to: SeekFrom) -> std::io::Result<u64> {
let pos = match to {
SeekFrom::Start(n) => Some(n),
SeekFrom::End(d) => self.len.checked_add_signed(d),
SeekFrom::Current(d) => self.pos.checked_add_signed(d),
};
self.pos = pos.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek before the archive")
})?;
Ok(self.pos)
}
}
pub(crate) fn read_trailer<R: Read + Seek>(
r: &mut R,
len: u64,
) -> Result<Option<Trailer>, ModelConfigError> {
let io = |e: std::io::Error| ModelConfigError::Container(format!("ZIP trailer: {e}"));
let tail_len = len.min(EOCD_SEARCH);
if tail_len < EOCD_LEN as u64 {
return Ok(None);
}
r.seek(SeekFrom::Start(len - tail_len)).map_err(io)?;
let mut tail = vec![0u8; tail_len as usize];
r.read_exact(&mut tail).map_err(io)?;
let Some(start) = archive_start(r, len, &tail) else {
return Ok(None);
};
let window = Window {
inner: &mut *r,
start,
len: len - start,
pos: 0,
};
let config = zip::read::Config {
archive_offset: zip::read::ArchiveOffset::Known(0),
};
let Ok(mut archive) = zip::ZipArchive::with_config(config, window) else {
log::debug!("model file ends with a ZIP directory that does not read as an archive");
return Ok(None);
};
let mut first = u64::MAX;
for i in 0..archive.len() {
if let Ok(file) = archive.by_index_raw(i) {
first = first.min(file.header_start());
}
}
if first == u64::MAX {
return Ok(None);
}
let mut entries = Vec::new();
for i in 0..archive.len() {
let Ok(mut file) = archive.by_index(i) else {
continue;
};
let Ok(name) = file.name().map(|n| n.into_owned()) else {
continue;
};
if file.is_dir() || file.size() > MAX_TEXT_ENTRY || file.compressed_size() > MAX_TEXT_ENTRY
{
log::debug!("ZIP entry {name} skipped");
continue;
}
let mut bytes = Vec::new();
let read = (&mut file).take(MAX_TEXT_ENTRY + 1).read_to_end(&mut bytes);
if read.is_err() || bytes.len() as u64 > MAX_TEXT_ENTRY {
continue;
}
if let Ok(text) = String::from_utf8(bytes) {
entries.push((name, text));
}
}
Ok(Some(Trailer {
payload_len: start + first,
entries,
}))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::io::{Cursor, Write};
pub(crate) fn with_trailer(payload: &[u8], files: &[(&str, &str)]) -> Vec<u8> {
let mut cursor = Cursor::new(payload.to_vec());
cursor.set_position(payload.len() as u64);
let mut zip = zip::ZipWriter::new(cursor);
for (name, text) in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn read(bytes: &[u8]) -> Option<Trailer> {
read_trailer(&mut Cursor::new(bytes), bytes.len() as u64).unwrap()
}
#[test]
fn zip_trailer_entries_after_payload() {
let payload = vec![0xabu8; 1000];
let bytes = with_trailer(
&payload,
&[("edgefirst.json", "{}"), ("labels.txt", "a\nb\n")],
);
let t = read(&bytes).expect("trailer");
assert_eq!(t.payload_len, 1000);
assert_eq!(t.entries.len(), 2);
assert!(t.entries.contains(&("labels.txt".into(), "a\nb\n".into())));
}
#[test]
fn concatenated_zip_reports_its_start() {
let zip = with_trailer(&[], &[("labels.txt", "a\n")]);
let mut bytes = vec![0x5au8; 777];
bytes.extend_from_slice(&zip);
let t = read(&bytes).expect("trailer");
assert_eq!(t.payload_len, 777);
assert_eq!(t.entries, [("labels.txt".to_string(), "a\n".to_string())]);
}
#[test]
fn oversized_entries_are_skipped() {
let big = "x".repeat((MAX_TEXT_ENTRY + 10) as usize);
let mut cursor = Cursor::new(vec![0u8; 16]);
cursor.set_position(16);
let mut zip = zip::ZipWriter::new(cursor);
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("big.txt", opts).unwrap();
zip.write_all(big.as_bytes()).unwrap();
zip.start_file("small.txt", opts).unwrap();
zip.write_all(b"ok").unwrap();
let bytes = zip.finish().unwrap().into_inner();
let t = read(&bytes).expect("trailer");
assert_eq!(t.entries, [("small.txt".to_string(), "ok".to_string())]);
}
#[test]
fn zip_trailer_with_out_of_bounds_cd_offset_is_ignored() {
let mut bytes = vec![0xaau8; 64];
bytes.extend_from_slice(&EOCD_MAGIC);
bytes.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0, 46, 0, 0, 0]);
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
bytes.extend_from_slice(&[0, 0]);
assert!(read(&bytes).is_none());
}
struct Counting<R> {
inner: R,
read: u64,
}
impl<R: Read> Read for Counting<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.read += n as u64;
Ok(n)
}
}
impl<R: Seek> Seek for Counting<R> {
fn seek(&mut self, to: SeekFrom) -> std::io::Result<u64> {
self.inner.seek(to)
}
}
fn bytes_read(bytes: Vec<u8>) -> (bool, u64) {
let len = bytes.len() as u64;
let mut r = Counting {
inner: Cursor::new(bytes),
read: 0,
};
let found = read_trailer(&mut r, len).unwrap().is_some();
(found, r.read)
}
#[test]
fn trailer_detection_reads_only_the_tail() {
const MODEL: usize = 8 << 20;
let zip = with_trailer(&[], &[("labels.txt", "a\n")]);
let mut concatenated = vec![0x5au8; MODEL];
concatenated.extend_from_slice(&zip);
let (found, read) = bytes_read(concatenated);
assert!(found);
assert!(read < 200 << 10, "read {read} bytes");
let mut fake = vec![0x5au8; MODEL];
fake[MODEL - 100..MODEL - 96].copy_from_slice(&EOCD_MAGIC);
let (found, read) = bytes_read(fake);
assert!(!found);
assert!(read <= EOCD_SEARCH + 64, "read {read} bytes");
}
#[test]
fn payload_ends_at_the_first_entry_even_if_unreadable() {
let mut cursor = Cursor::new(vec![0u8; 100]);
cursor.set_position(100);
let mut zip = zip::ZipWriter::new(cursor);
let stored = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
zip.start_file("first.bin", stored).unwrap();
zip.write_all(b"x").unwrap();
zip.start_file("labels.txt", stored).unwrap();
zip.write_all(b"a\n").unwrap();
let mut bytes = zip.finish().unwrap().into_inner();
bytes[100 + 8] = 77;
let cd = bytes.windows(4).position(|w| w == CDFH_MAGIC).unwrap();
bytes[cd + 10] = 77;
let t = read(&bytes).expect("trailer");
assert_eq!(t.payload_len, 100);
assert_eq!(t.entries, [("labels.txt".to_string(), "a\n".to_string())]);
}
#[test]
fn no_trailer_no_metadata() {
assert!(read(&[0u8; 4096]).is_none());
assert!(read(&[1, 2, 3]).is_none());
assert!(read(&[]).is_none());
}
}