use crate::error::Error;
const EOCD_SIG: u32 = 0x0605_4b50;
const CD_HEADER_SIG: u32 = 0x0201_4b50;
const LOCAL_HEADER_SIG: u32 = 0x0403_4b50;
const EOCD_MIN_LEN: usize = 22;
const CD_HEADER_FIXED_LEN: usize = 46;
const LOCAL_HEADER_FIXED_LEN: usize = 30;
const ZIP64_SENTINEL_U32: u32 = 0xFFFF_FFFF;
const ZIP64_SENTINEL_U16: u16 = 0xFFFF;
pub const ZIP_MANIFEST_PATH: &str = "META-INF/content_credential.c2pa";
fn read_u16(buf: &[u8], at: usize) -> Result<u16, Error> {
buf.get(at..at + 2)
.map(|b| u16::from_le_bytes([b[0], b[1]]))
.ok_or(Error::Truncated)
}
fn read_u32(buf: &[u8], at: usize) -> Result<u32, Error> {
buf.get(at..at + 4)
.map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.ok_or(Error::Truncated)
}
struct CdEntry {
name: String,
local_header_offset: usize,
cd_header_offset: usize,
cd_header_len: usize,
}
struct ZipLayout {
entries: Vec<CdEntry>,
cd_start: usize,
eocd_offset: usize,
}
fn entry_data_range(layout: &ZipLayout, i: usize) -> Result<std::ops::Range<usize>, Error> {
let start = layout.entries[i].local_header_offset;
let end = layout
.entries
.get(i + 1)
.map(|n| n.local_header_offset)
.unwrap_or(layout.cd_start);
if start > end || end > layout.cd_start {
return Err(Error::BadOffset);
}
Ok(start..end)
}
fn find_eocd(bytes: &[u8]) -> Result<usize, Error> {
if bytes.len() < EOCD_MIN_LEN {
return Err(Error::NoEocd);
}
let max_comment = 0xFFFF;
let scan_start = bytes.len().saturating_sub(EOCD_MIN_LEN + max_comment);
for pos in (scan_start..=bytes.len() - EOCD_MIN_LEN).rev() {
if read_u32(bytes, pos)? == EOCD_SIG {
let comment_len = read_u16(bytes, pos + 20)? as usize;
if pos + EOCD_MIN_LEN + comment_len == bytes.len() {
return Ok(pos);
}
}
}
Err(Error::NoEocd)
}
fn parse_layout(bytes: &[u8]) -> Result<ZipLayout, Error> {
let eocd = find_eocd(bytes)?;
let total_entries = read_u16(bytes, eocd + 10)?;
let cd_size = read_u32(bytes, eocd + 12)?;
let cd_offset = read_u32(bytes, eocd + 16)?;
let comment_len = read_u16(bytes, eocd + 20)? as usize;
if total_entries == ZIP64_SENTINEL_U16
|| cd_size == ZIP64_SENTINEL_U32
|| cd_offset == ZIP64_SENTINEL_U32
{
return Err(Error::Zip64Unsupported);
}
let cd_start = cd_offset as usize;
let cd_through_eocd_end = eocd
.checked_add(EOCD_MIN_LEN)
.and_then(|e| e.checked_add(comment_len))
.ok_or(Error::Truncated)?;
if cd_start > eocd || cd_through_eocd_end > bytes.len() {
return Err(Error::BadOffset);
}
let mut entries = Vec::with_capacity(total_entries as usize);
let mut cursor = cd_start;
for _ in 0..total_entries {
if read_u32(bytes, cursor)? != CD_HEADER_SIG {
return Err(Error::Truncated);
}
let name_len = read_u16(bytes, cursor + 28)? as usize;
let extra_len = read_u16(bytes, cursor + 30)? as usize;
let comment = read_u16(bytes, cursor + 32)? as usize;
let local_off = read_u32(bytes, cursor + 42)?;
if local_off == ZIP64_SENTINEL_U32 {
return Err(Error::Zip64Unsupported);
}
let name_start = cursor
.checked_add(CD_HEADER_FIXED_LEN)
.ok_or(Error::Truncated)?;
let name_end = name_start.checked_add(name_len).ok_or(Error::Truncated)?;
let name_bytes = bytes.get(name_start..name_end).ok_or(Error::Truncated)?;
let name = std::str::from_utf8(name_bytes)
.map_err(|_| Error::NonUtf8Name)?
.to_string();
let local_off = local_off as usize;
if local_off >= cd_start {
return Err(Error::BadOffset);
}
let next = name_end
.checked_add(extra_len)
.and_then(|c| c.checked_add(comment))
.ok_or(Error::Truncated)?;
if next > eocd {
return Err(Error::Truncated);
}
entries.push(CdEntry {
name,
local_header_offset: local_off,
cd_header_offset: cursor,
cd_header_len: next - cursor,
});
cursor = next;
}
entries.sort_by_key(|e| e.local_header_offset);
Ok(ZipLayout {
entries,
cd_start,
eocd_offset: eocd,
})
}
fn write_local_header(out: &mut Vec<u8>, name: &[u8], crc: u32, size: u32, name_len: u16) {
out.extend_from_slice(&LOCAL_HEADER_SIG.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&size.to_le_bytes()); out.extend_from_slice(&size.to_le_bytes()); out.extend_from_slice(&name_len.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(name);
}
fn write_cd_header(
out: &mut Vec<u8>,
name: &[u8],
crc: u32,
size: u32,
name_len: u16,
local_off: u32,
) {
out.extend_from_slice(&CD_HEADER_SIG.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes()); out.extend_from_slice(&20u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&size.to_le_bytes()); out.extend_from_slice(&size.to_le_bytes()); out.extend_from_slice(&name_len.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u32.to_le_bytes()); out.extend_from_slice(&local_off.to_le_bytes());
out.extend_from_slice(name);
}
fn crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
pub(crate) fn insert_zip_entry(bytes: &[u8], name: &str, content: &[u8]) -> Result<Vec<u8>, Error> {
let layout = parse_layout(bytes)?;
let name_b = name.as_bytes();
let name_len = u16::try_from(name_b.len()).map_err(|_| Error::Truncated)?;
let size = u32::try_from(content.len()).map_err(|_| Error::Truncated)?;
let crc = crc32(content);
let manifest_local_offset =
u32::try_from(layout.cd_start).map_err(|_| Error::Zip64Unsupported)?;
let mut out = Vec::with_capacity(bytes.len() + content.len() + 128);
out.extend_from_slice(&bytes[..layout.cd_start]);
write_local_header(&mut out, name_b, crc, size, name_len);
out.extend_from_slice(content);
let new_cd_start = u32::try_from(out.len()).map_err(|_| Error::Zip64Unsupported)?;
out.extend_from_slice(&bytes[layout.cd_start..layout.eocd_offset]);
write_cd_header(&mut out, name_b, crc, size, name_len, manifest_local_offset);
let new_cd_size =
u32::try_from(out.len() - new_cd_start as usize).map_err(|_| Error::Truncated)?;
let total_entries =
u16::try_from(layout.entries.len() + 1).map_err(|_| Error::Zip64Unsupported)?;
out.extend_from_slice(&EOCD_SIG.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&total_entries.to_le_bytes()); out.extend_from_slice(&total_entries.to_le_bytes()); out.extend_from_slice(&new_cd_size.to_le_bytes());
out.extend_from_slice(&new_cd_start.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
Ok(out)
}
fn entry_content_range(
bytes: &[u8],
layout: &ZipLayout,
i: usize,
) -> Result<std::ops::Range<usize>, Error> {
let lh = layout.entries[i].local_header_offset;
if read_u32(bytes, lh)? != LOCAL_HEADER_SIG {
return Err(Error::Truncated);
}
let comp_size = read_u32(bytes, lh + 18)? as usize;
let name_len = read_u16(bytes, lh + 26)? as usize;
let extra_len = read_u16(bytes, lh + 28)? as usize;
let start = lh
.checked_add(LOCAL_HEADER_FIXED_LEN)
.and_then(|c| c.checked_add(name_len))
.and_then(|c| c.checked_add(extra_len))
.ok_or(Error::Truncated)?;
let end = start.checked_add(comp_size).ok_or(Error::Truncated)?;
if end > entry_data_range(layout, i)?.end {
return Err(Error::BadOffset);
}
if end > bytes.len() {
return Err(Error::Truncated);
}
Ok(start..end)
}
pub(crate) fn read_zip_entry_content<'a>(
bytes: &'a [u8],
name: &str,
) -> Result<Option<&'a [u8]>, Error> {
let layout = parse_layout(bytes)?;
for (i, entry) in layout.entries.iter().enumerate() {
if entry.name != name {
continue;
}
let range = entry_content_range(bytes, &layout, i)?;
return bytes.get(range).map(Some).ok_or(Error::Truncated);
}
Ok(None)
}
pub(crate) fn member_ranges(bytes: &[u8]) -> Result<Vec<(String, std::ops::Range<usize>)>, Error> {
let layout = parse_layout(bytes)?;
(0..layout.entries.len())
.map(|i| {
Ok((
layout.entries[i].name.clone(),
entry_content_range(bytes, &layout, i)?,
))
})
.collect()
}
pub(crate) fn central_directory_range(bytes: &[u8]) -> Result<std::ops::Range<usize>, Error> {
let layout = parse_layout(bytes)?;
if layout.cd_start > bytes.len() || layout.cd_start > layout.eocd_offset {
return Err(Error::BadOffset);
}
Ok(layout.cd_start..bytes.len())
}
pub(crate) fn remove_zip_entry(bytes: &[u8], name: &str) -> Result<Vec<u8>, Error> {
let layout = parse_layout(bytes)?;
if !layout.entries.iter().any(|e| e.name == name) {
return Ok(bytes.to_vec());
}
let mut out = Vec::with_capacity(bytes.len());
let mut retained: Vec<(&CdEntry, u32)> = Vec::with_capacity(layout.entries.len());
for (i, entry) in layout.entries.iter().enumerate() {
let range = entry_data_range(&layout, i)?;
if entry.name == name {
continue;
}
let data = bytes.get(range).ok_or(Error::Truncated)?;
let new_off = u32::try_from(out.len()).map_err(|_| Error::Zip64Unsupported)?;
out.extend_from_slice(data);
retained.push((entry, new_off));
}
let new_cd_start = u32::try_from(out.len()).map_err(|_| Error::Zip64Unsupported)?;
for (entry, new_off) in &retained {
let hdr = bytes
.get(entry.cd_header_offset..entry.cd_header_offset + entry.cd_header_len)
.ok_or(Error::Truncated)?;
let start = out.len();
out.extend_from_slice(hdr);
out[start + 42..start + 46].copy_from_slice(&new_off.to_le_bytes());
}
let cd_size = u32::try_from(out.len() - new_cd_start as usize).map_err(|_| Error::Truncated)?;
let total_entries = u16::try_from(retained.len()).map_err(|_| Error::Zip64Unsupported)?;
out.extend_from_slice(&EOCD_SIG.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&total_entries.to_le_bytes()); out.extend_from_slice(&total_entries.to_le_bytes()); out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&new_cd_start.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
Ok(out)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) fn build_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = Vec::new();
let mut cd = Vec::new();
let mut offsets = Vec::new();
for (name, data) in files {
let local_off = out.len() as u32;
offsets.push(local_off);
let name_b = name.as_bytes();
write_local_header(
&mut out,
name_b,
crc32(data),
data.len() as u32,
name_b.len() as u16,
);
out.extend_from_slice(data);
}
let cd_start = out.len() as u32;
for (i, (name, data)) in files.iter().enumerate() {
let name_b = name.as_bytes();
write_cd_header(
&mut cd,
name_b,
crc32(data),
data.len() as u32,
name_b.len() as u16,
offsets[i],
);
}
let cd_size = cd.len() as u32;
out.extend_from_slice(&cd);
out.extend_from_slice(&EOCD_SIG.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&(files.len() as u16).to_le_bytes()); out.extend_from_slice(&(files.len() as u16).to_le_bytes()); out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd_start.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); out
}
#[test]
fn parses_entries_in_offset_order() {
let zip = build_zip(&[
("mimetype", b"application/epub+zip"),
("content.xml", b"<doc>hello</doc>"),
]);
let layout = parse_layout(&zip).unwrap();
assert_eq!(layout.entries.len(), 2);
assert_eq!(layout.entries[0].name, "mimetype");
assert_eq!(layout.entries[1].name, "content.xml");
}
#[test]
fn tolerates_trailing_comment() {
let mut zip = build_zip(&[("a.txt", b"AAAA")]);
let eocd = zip.len() - EOCD_MIN_LEN;
let comment = b"a trailing zip comment";
let clen = comment.len() as u16;
zip[eocd + 20..eocd + 22].copy_from_slice(&clen.to_le_bytes());
zip.extend_from_slice(comment);
let layout = parse_layout(&zip).unwrap();
assert_eq!(layout.entries.len(), 1);
}
#[test]
fn insert_appends_and_keeps_existing_bytes_stable() {
let zip = build_zip(&[("content.xml", b"<doc/>"), ("styles.xml", b"body{}")]);
let cd_start = parse_layout(&zip).unwrap().cd_start;
let out = insert_zip_entry(&zip, ZIP_MANIFEST_PATH, b"MANIFEST").unwrap();
assert_eq!(&out[..cd_start], &zip[..cd_start]);
let content = read_zip_entry_content(&out, ZIP_MANIFEST_PATH)
.unwrap()
.unwrap();
assert_eq!(content, b"MANIFEST");
}
#[test]
fn insert_sets_valid_crc() {
let zip = build_zip(&[("a.txt", b"AAAA")]);
let out = insert_zip_entry(&zip, ZIP_MANIFEST_PATH, b"hello world").unwrap();
let layout = parse_layout(&out).unwrap();
let m = layout
.entries
.iter()
.find(|e| e.name == ZIP_MANIFEST_PATH)
.unwrap();
let local_crc = read_u32(&out, m.local_header_offset + 14).unwrap();
let cd_crc = read_u32(&out, m.cd_header_offset + 16).unwrap();
assert_eq!(local_crc, crc32(b"hello world"));
assert_eq!(cd_crc, crc32(b"hello world"));
}
#[test]
fn read_absent_entry_is_none() {
let zip = build_zip(&[("a.txt", b"AAAA")]);
assert!(read_zip_entry_content(&zip, ZIP_MANIFEST_PATH)
.unwrap()
.is_none());
}
#[test]
fn remove_absent_entry_is_byte_identical() {
let zip = build_zip(&[("a.txt", b"AAAA")]);
let out = remove_zip_entry(&zip, ZIP_MANIFEST_PATH).unwrap();
assert_eq!(out, zip);
}
#[test]
fn remove_middle_entry_rebuilds_valid_archive() {
let zip = build_zip(&[("a.txt", b"AAAA"), ("b.txt", b"BBBB"), ("c.txt", b"CCCC")]);
let out = remove_zip_entry(&zip, "b.txt").unwrap();
let layout = parse_layout(&out).unwrap();
assert_eq!(layout.entries.len(), 2);
assert_eq!(
read_zip_entry_content(&out, "a.txt").unwrap().unwrap(),
b"AAAA"
);
assert_eq!(
read_zip_entry_content(&out, "c.txt").unwrap().unwrap(),
b"CCCC"
);
assert!(read_zip_entry_content(&out, "b.txt").unwrap().is_none());
}
#[test]
fn rejects_non_zip() {
assert!(matches!(
parse_layout(b"not a zip file at all"),
Err(Error::NoEocd)
));
assert!(matches!(parse_layout(&[]), Err(Error::NoEocd)));
}
#[test]
fn rejects_zip64_sentinels() {
let mut zip = build_zip(&[("a.txt", b"AAAA")]);
let eocd = zip.len() - EOCD_MIN_LEN;
zip[eocd + 16..eocd + 20].copy_from_slice(&ZIP64_SENTINEL_U32.to_le_bytes());
assert!(matches!(parse_layout(&zip), Err(Error::Zip64Unsupported)));
}
#[test]
fn rejects_truncation() {
let zip = build_zip(&[("a.txt", b"AAAA")]);
let truncated = &zip[..zip.len() - 1];
assert!(parse_layout(truncated).is_err());
}
#[test]
fn rejects_out_of_range_cd_offset() {
let mut zip = build_zip(&[("a.txt", b"AAAA")]);
let eocd = zip.len() - EOCD_MIN_LEN;
let bad = (eocd + 1) as u32;
zip[eocd + 16..eocd + 20].copy_from_slice(&bad.to_le_bytes());
assert!(matches!(parse_layout(&zip), Err(Error::BadOffset)));
}
}