use crate::{GedcomError, GedcomErrorKind};
use flate2::Compression;
use flate2::read::DeflateDecoder;
use flate2::write::DeflateEncoder;
use std::collections::HashSet;
use std::io::{Read, Write};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct ArchiveLimits {
pub entries: usize,
pub entry_bytes: u64,
pub total_bytes: u64,
pub ratio: u64,
}
impl ArchiveLimits {
pub const DEFAULT: Self = Self {
entries: 100_000,
entry_bytes: 1024 * 1024 * 1024,
total_bytes: 4 * 1024 * 1024 * 1024,
ratio: 1000,
};
#[must_use]
pub const fn with_entries(mut self, entries: usize) -> Self {
self.entries = entries;
self
}
#[must_use]
pub const fn with_entry_bytes(mut self, entry_bytes: u64) -> Self {
self.entry_bytes = entry_bytes;
self
}
#[must_use]
pub const fn with_total_bytes(mut self, total_bytes: u64) -> Self {
self.total_bytes = total_bytes;
self
}
#[must_use]
pub const fn with_ratio(mut self, ratio: u64) -> Self {
self.ratio = ratio;
self
}
}
impl Default for ArchiveLimits {
fn default() -> Self {
Self::DEFAULT
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub struct ArchiveEntry {
pub name: String,
pub size: u64,
pub compressed_size: u64,
pub is_directory: bool,
compression: u16,
flags: u16,
crc32: u32,
local_offset: u64,
}
#[derive(Debug)]
pub struct Archive<'a> {
bytes: &'a [u8],
entries: Vec<ArchiveEntry>,
limits: ArchiveLimits,
}
const LOCAL_HEADER: u32 = 0x0403_4b50;
const CENTRAL_HEADER: u32 = 0x0201_4b50;
const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
const ZIP64_END_OF_CENTRAL_DIRECTORY: u32 = 0x0606_4b50;
const ZIP64_LOCATOR: u32 = 0x0706_4b50;
const ZIP64_EXTRA_FIELD: u16 = 0x0001;
const STORED: u16 = 0;
const DEFLATED: u16 = 8;
const NEEDS_ZIP64: u32 = 0xFFFF_FFFF;
const PLAIN_VERSION_NEEDED: u16 = 20;
const ZIP64_VERSION_NEEDED: u16 = 45;
const UTF8_NAME_FLAG: u16 = 0x0800;
const MS_DOS_DIRECTORY_ATTRIBUTE: u32 = 0x10;
impl<'a> Archive<'a> {
#[must_use]
pub fn looks_like_archive(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK\x03\x04") || bytes.starts_with(b"PK\x05\x06")
}
pub fn open(bytes: &'a [u8]) -> Result<Self, GedcomError> {
Self::open_with(bytes, ArchiveLimits::DEFAULT)
}
pub fn open_with(bytes: &'a [u8], limits: ArchiveLimits) -> Result<Self, GedcomError> {
let (directory_offset, entry_count) = locate_directory(bytes)?;
if entry_count > limits.entries {
return Err(fault(format!(
"the archive holds {entry_count} files and the limit is {}",
limits.entries
)));
}
let mut entries = Vec::with_capacity(entry_count.min(1024));
let mut paths = HashSet::with_capacity(entry_count.min(1024));
let mut cursor = usize::try_from(directory_offset)
.map_err(|_| fault("the archive's directory is beyond the end of the file"))?;
let mut total = 0u64;
for index in 0..entry_count {
let header = read_u32(bytes, cursor)?;
if header != CENTRAL_HEADER {
return Err(fault(format!(
"file {} of the archive has no directory record",
index + 1
)));
}
let flags = read_u16(bytes, checked_offset(cursor, 8)?)?;
if flags & 1 != 0 {
return Err(fault("encrypted entries are not supported"));
}
let compression = read_u16(bytes, checked_offset(cursor, 10)?)?;
let crc32 = read_u32(bytes, checked_offset(cursor, 16)?)?;
let mut compressed_size = u64::from(read_u32(bytes, checked_offset(cursor, 20)?)?);
let mut size = u64::from(read_u32(bytes, checked_offset(cursor, 24)?)?);
let name_length = usize::from(read_u16(bytes, checked_offset(cursor, 28)?)?);
let extra_length = usize::from(read_u16(bytes, checked_offset(cursor, 30)?)?);
let comment_length = usize::from(read_u16(bytes, checked_offset(cursor, 32)?)?);
let external_attributes = read_u32(bytes, checked_offset(cursor, 38)?)?;
let mut local_offset = u64::from(read_u32(bytes, checked_offset(cursor, 42)?)?);
let name_start = checked_offset(cursor, 46)?;
let name_bytes = slice(bytes, name_start, name_length)?;
let name = String::from_utf8(name_bytes.to_vec())
.map_err(|_| fault("a file in the archive has a name that is not valid UTF-8"))?;
let extra_start = checked_offset(name_start, name_length)?;
let extra = slice(bytes, extra_start, extra_length)?;
read_zip64_extra(extra, &mut size, &mut compressed_size, &mut local_offset);
if is_symbolic_link(external_attributes) {
return Err(fault(format!(
"the archive contains a symbolic link ({name}), which is not extracted"
)));
}
let is_directory = name.ends_with('/');
validate_entry_path(&name)?;
let path_key = name.trim_end_matches('/').to_ascii_lowercase();
if !paths.insert(path_key) {
return Err(fault(format!(
"the archive contains the same path more than once ({name})"
)));
}
if size > limits.entry_bytes {
return Err(fault(format!(
"{name} expands to {size} bytes and the per-file limit is {}",
limits.entry_bytes
)));
}
if size > 0
&& (compressed_size == 0 || size > compressed_size.saturating_mul(limits.ratio))
{
return Err(fault(format!(
"{name} claims a compression ratio over the {}-fold limit",
limits.ratio
)));
}
total = total.saturating_add(size);
if total > limits.total_bytes {
return Err(fault(format!(
"the archive expands to more than the {} byte limit",
limits.total_bytes
)));
}
if !matches!(compression, STORED | DEFLATED) {
return Err(fault(format!(
"{name} uses compression method {compression}, which is not supported"
)));
}
entries.push(ArchiveEntry {
name,
size,
compressed_size,
is_directory,
compression,
flags,
crc32,
local_offset,
});
cursor = checked_offset(checked_offset(extra_start, extra_length)?, comment_length)?;
}
Ok(Self {
bytes,
entries,
limits,
})
}
#[must_use]
pub fn entries(&self) -> &[ArchiveEntry] {
&self.entries
}
#[must_use]
pub fn total_size(&self) -> u64 {
self.entries
.iter()
.fold(0u64, |total, entry| total.saturating_add(entry.size))
}
pub fn document_name(&self) -> Result<&str, GedcomError> {
let root_documents: Vec<&ArchiveEntry> = self
.entries
.iter()
.filter(|entry| {
!entry.is_directory
&& !entry.name.contains('/')
&& entry.name.to_ascii_lowercase().ends_with(".ged")
})
.collect();
if let Some(specified) = root_documents
.iter()
.find(|entry| entry.name.eq_ignore_ascii_case("gedcom.ged"))
{
return Ok(&specified.name);
}
match root_documents.as_slice() {
[] => Err(fault(
"the archive holds no GEDCOM document at its root, so it is not a GEDZIP",
)),
[only] => Ok(&only.name),
many => Err(fault(format!(
"the archive holds {} GEDCOM documents at its root and none is named gedcom.ged, \
so which one is the tree is ambiguous",
many.len()
))),
}
}
pub fn read(&self, name: &str) -> Result<Vec<u8>, GedcomError> {
let entry = self
.entries
.iter()
.find(|entry| entry.name == name)
.ok_or_else(|| fault(format!("the archive has no file named {name}")))?;
self.read_entry(entry)
}
pub fn read_entry(&self, entry: &ArchiveEntry) -> Result<Vec<u8>, GedcomError> {
if entry.is_directory {
return Err(fault(format!("{} is a directory, not a file", entry.name)));
}
let offset = usize::try_from(entry.local_offset)
.map_err(|_| fault("a file in the archive begins beyond the end of the file"))?;
if read_u32(self.bytes, offset)? != LOCAL_HEADER {
return Err(fault(format!("{} has no file header", entry.name)));
}
let local_flags = read_u16(self.bytes, checked_offset(offset, 6)?)?;
let local_compression = read_u16(self.bytes, checked_offset(offset, 8)?)?;
if local_flags != entry.flags || local_compression != entry.compression {
return Err(fault(format!(
"{} has a local header that disagrees with its directory record",
entry.name
)));
}
let name_length = usize::from(read_u16(self.bytes, checked_offset(offset, 26)?)?);
let extra_length = usize::from(read_u16(self.bytes, checked_offset(offset, 28)?)?);
let name_start = checked_offset(offset, 30)?;
let local_name = slice(self.bytes, name_start, name_length)?;
if local_name != entry.name.as_bytes() {
return Err(fault(format!(
"{} has a local name that disagrees with its directory record",
entry.name
)));
}
let start = checked_offset(checked_offset(name_start, name_length)?, extra_length)?;
let compressed_length = usize::try_from(entry.compressed_size)
.map_err(|_| fault(format!("{} is too large to read", entry.name)))?;
let data = slice(self.bytes, start, compressed_length)?;
let expanded = match entry.compression {
STORED => data.to_vec(),
DEFLATED => inflate(data, entry.size, self.limits.entry_bytes, &entry.name)?,
other => {
return Err(fault(format!(
"{} uses compression method {other}, which is not supported",
entry.name
)));
}
};
if u64::try_from(expanded.len()).unwrap_or(u64::MAX) != entry.size {
return Err(fault(format!(
"{} expanded to {} bytes where the archive said {}",
entry.name,
expanded.len(),
entry.size
)));
}
let mut crc = flate2::Crc::new();
crc.update(&expanded);
if crc.sum() != entry.crc32 {
return Err(fault(format!("{} failed its checksum", entry.name)));
}
Ok(expanded)
}
}
fn plain_u32_field(value: usize, message: impl FnOnce() -> String) -> Result<u32, GedcomError> {
match u32::try_from(value) {
Ok(small) if small < NEEDS_ZIP64 => Ok(small),
_ => Err(fault(message())),
}
}
fn plain_u16_field(value: usize, message: impl FnOnce() -> String) -> Result<u16, GedcomError> {
match u16::try_from(value) {
Ok(small) if small < u16::MAX => Ok(small),
_ => Err(fault(message())),
}
}
pub fn write_archive(entries: &[(String, Vec<u8>)]) -> Result<Vec<u8>, GedcomError> {
let mut output = Vec::new();
let mut directory = Vec::new();
let mut paths = HashSet::with_capacity(entries.len().min(1024));
for (name, content) in entries {
validate_entry_path(name)?;
if !paths.insert(name.trim_end_matches('/').to_ascii_lowercase()) {
return Err(fault(format!(
"the archive contains the same path more than once ({name})"
)));
}
let local_offset = plain_u32_field(output.len(), || {
"the archive is larger than this writer can address".to_owned()
})?;
let mut crc = flate2::Crc::new();
crc.update(content);
let checksum = crc.sum();
let (compression, payload) = if should_deflate(name) {
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(content)
.and_then(|()| encoder.finish())
.map_or_else(
|_| (STORED, content.clone()),
|compressed| {
if compressed.len() < content.len() {
(DEFLATED, compressed)
} else {
(STORED, content.clone())
}
},
)
} else {
(STORED, content.clone())
};
let uncompressed_size = plain_u32_field(content.len(), || {
format!("{name} is too large for this writer")
})?;
let compressed_size = plain_u32_field(payload.len(), || {
format!("{name} is too large for this writer")
})?;
let name_bytes = name.as_bytes();
let name_length = u16::try_from(name_bytes.len())
.map_err(|_| fault(format!("{name} has a path longer than a ZIP allows")))?;
let external_attributes = if name.ends_with('/') {
MS_DOS_DIRECTORY_ATTRIBUTE
} else {
0
};
output.extend_from_slice(&LOCAL_HEADER.to_le_bytes());
output.extend_from_slice(&20u16.to_le_bytes()); output.extend_from_slice(&UTF8_NAME_FLAG.to_le_bytes());
output.extend_from_slice(&compression.to_le_bytes());
output.extend_from_slice(&0u16.to_le_bytes()); output.extend_from_slice(&0x21u16.to_le_bytes()); output.extend_from_slice(&checksum.to_le_bytes());
output.extend_from_slice(&compressed_size.to_le_bytes());
output.extend_from_slice(&uncompressed_size.to_le_bytes());
output.extend_from_slice(&name_length.to_le_bytes());
output.extend_from_slice(&0u16.to_le_bytes()); output.extend_from_slice(name_bytes);
output.extend_from_slice(&payload);
directory.extend_from_slice(&CENTRAL_HEADER.to_le_bytes());
directory.extend_from_slice(&20u16.to_le_bytes()); directory.extend_from_slice(&20u16.to_le_bytes()); directory.extend_from_slice(&UTF8_NAME_FLAG.to_le_bytes());
directory.extend_from_slice(&compression.to_le_bytes());
directory.extend_from_slice(&0u16.to_le_bytes());
directory.extend_from_slice(&0x21u16.to_le_bytes());
directory.extend_from_slice(&checksum.to_le_bytes());
directory.extend_from_slice(&compressed_size.to_le_bytes());
directory.extend_from_slice(&uncompressed_size.to_le_bytes());
directory.extend_from_slice(&name_length.to_le_bytes());
directory.extend_from_slice(&0u16.to_le_bytes()); directory.extend_from_slice(&0u16.to_le_bytes()); directory.extend_from_slice(&0u16.to_le_bytes()); directory.extend_from_slice(&0u16.to_le_bytes()); directory.extend_from_slice(&external_attributes.to_le_bytes());
directory.extend_from_slice(&local_offset.to_le_bytes());
directory.extend_from_slice(name_bytes);
}
let directory_offset = plain_u32_field(output.len(), || {
"the archive is larger than this writer can address".to_owned()
})?;
let directory_size = plain_u32_field(directory.len(), || {
"the archive is larger than this writer can address".to_owned()
})?;
let count = plain_u16_field(entries.len(), || {
"the archive holds more files than this writer can address".to_owned()
})?;
output.extend_from_slice(&directory);
output.extend_from_slice(&END_OF_CENTRAL_DIRECTORY.to_le_bytes());
output.extend_from_slice(&0u16.to_le_bytes()); output.extend_from_slice(&0u16.to_le_bytes()); output.extend_from_slice(&count.to_le_bytes());
output.extend_from_slice(&count.to_le_bytes());
output.extend_from_slice(&directory_size.to_le_bytes());
output.extend_from_slice(&directory_offset.to_le_bytes());
output.extend_from_slice(&0u16.to_le_bytes()); Ok(output)
}
#[non_exhaustive]
pub struct ArchiveWriter<W: Write> {
sink: CountingWriter<W>,
directory: Vec<u8>,
paths: HashSet<String>,
limits: ArchiveLimits,
poisoned: bool,
}
impl<W: Write> std::fmt::Debug for ArchiveWriter<W> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ArchiveWriter")
.field("entries_written", &self.paths.len())
.field("bytes_written", &self.sink.position)
.field("limits", &self.limits)
.field("poisoned", &self.poisoned)
.finish_non_exhaustive()
}
}
impl<W: Write> ArchiveWriter<W> {
pub fn new(sink: W) -> Self {
Self::new_with(sink, ArchiveLimits::DEFAULT)
}
pub fn new_with(sink: W, limits: ArchiveLimits) -> Self {
Self {
sink: CountingWriter::new(sink),
directory: Vec::new(),
paths: HashSet::new(),
limits,
poisoned: false,
}
}
pub fn add<R: Read>(&mut self, name: &str, mut source: R) -> Result<(), GedcomError> {
if self.poisoned {
return Err(fault(
"a previous write to this archive's sink failed, so the sink may hold a \
partial record; no further entries can be added",
));
}
validate_entry_path(name)?;
let path_key = name.trim_end_matches('/').to_ascii_lowercase();
if self.paths.contains(&path_key) {
return Err(fault(format!(
"the archive contains the same path more than once ({name})"
)));
}
if self.paths.len() >= self.limits.entries {
return Err(fault(format!(
"the archive already holds {} files and the limit is {}",
self.paths.len(),
self.limits.entries
)));
}
let content = read_bounded(&mut source, self.limits.entry_bytes, name)?;
let uncompressed_size = u64::try_from(content.len()).unwrap_or(u64::MAX);
let mut crc = flate2::Crc::new();
crc.update(&content);
let checksum = crc.sum();
let (compression, payload) = if should_deflate(name) {
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
let deflated = encoder
.write_all(&content)
.and_then(|()| encoder.finish())
.ok()
.filter(|compressed| compressed.len() < content.len());
deflated.map_or((STORED, content), |compressed| (DEFLATED, compressed))
} else {
(STORED, content)
};
let compressed_size = u64::try_from(payload.len()).unwrap_or(u64::MAX);
let name_bytes = name.as_bytes();
let name_length = u16::try_from(name_bytes.len())
.map_err(|_| fault(format!("{name} has a path longer than a ZIP allows")))?;
let local_offset = self.sink.position;
self.write_local_record(
name_bytes,
name_length,
compression,
checksum,
uncompressed_size,
compressed_size,
&payload,
)?;
let (size_field, size_zip64) = zip64_field(uncompressed_size);
let (compressed_field, compressed_zip64) = zip64_field(compressed_size);
self.directory.extend_from_slice(¢ral_record_bytes(
name,
name_bytes,
name_length,
compression,
checksum,
size_field,
compressed_field,
size_zip64,
compressed_zip64,
local_offset,
));
self.paths.insert(path_key);
Ok(())
}
#[allow(clippy::too_many_arguments, reason = "one ZIP record, one call site")]
fn write_local_record(
&mut self,
name_bytes: &[u8],
name_length: u16,
compression: u16,
checksum: u32,
uncompressed_size: u64,
compressed_size: u64,
payload: &[u8],
) -> Result<(), GedcomError> {
let (local_size_field, local_compressed_field, local_extra) =
local_zip64_sizes(uncompressed_size, compressed_size);
let local_version = if local_extra.is_empty() {
PLAIN_VERSION_NEEDED
} else {
ZIP64_VERSION_NEEDED
};
self.write_all(&LOCAL_HEADER.to_le_bytes())?;
self.write_all(&local_version.to_le_bytes())?;
self.write_all(&UTF8_NAME_FLAG.to_le_bytes())?;
self.write_all(&compression.to_le_bytes())?;
self.write_all(&0u16.to_le_bytes())?; self.write_all(&0x21u16.to_le_bytes())?; self.write_all(&checksum.to_le_bytes())?;
self.write_all(&local_compressed_field.to_le_bytes())?;
self.write_all(&local_size_field.to_le_bytes())?;
self.write_all(&name_length.to_le_bytes())?;
self.write_all(
&u16::try_from(local_extra.len())
.unwrap_or(u16::MAX)
.to_le_bytes(),
)?;
self.write_all(name_bytes)?;
self.write_all(&local_extra)?;
self.write_all(payload)
}
pub fn finish(mut self) -> Result<W, GedcomError> {
if self.poisoned {
return Err(fault(
"a previous write to this archive's sink failed, so the sink may hold a \
partial record; the archive cannot be finished",
));
}
let directory_offset = self.sink.position;
let directory = std::mem::take(&mut self.directory);
self.write_all(&directory)?;
let directory_size = u64::try_from(directory.len()).unwrap_or(u64::MAX);
let count = u64::try_from(self.paths.len()).unwrap_or(u64::MAX);
let needs_zip64 = count >= u64::from(u16::MAX)
|| directory_offset >= u64::from(u32::MAX)
|| directory_size >= u64::from(u32::MAX);
if needs_zip64 {
let zip64_record_offset = self.sink.position;
self.write_all(&ZIP64_END_OF_CENTRAL_DIRECTORY.to_le_bytes())?;
self.write_all(&44u64.to_le_bytes())?; self.write_all(&ZIP64_VERSION_NEEDED.to_le_bytes())?; self.write_all(&ZIP64_VERSION_NEEDED.to_le_bytes())?; self.write_all(&0u32.to_le_bytes())?; self.write_all(&0u32.to_le_bytes())?; self.write_all(&count.to_le_bytes())?; self.write_all(&count.to_le_bytes())?; self.write_all(&directory_size.to_le_bytes())?;
self.write_all(&directory_offset.to_le_bytes())?;
self.write_all(&ZIP64_LOCATOR.to_le_bytes())?;
self.write_all(&0u32.to_le_bytes())?; self.write_all(&zip64_record_offset.to_le_bytes())?;
self.write_all(&1u32.to_le_bytes())?; }
let eocd_count = u16::try_from(count).unwrap_or(u16::MAX);
let (eocd_directory_size, _) = zip64_field(directory_size);
let (eocd_directory_offset, _) = zip64_field(directory_offset);
self.write_all(&END_OF_CENTRAL_DIRECTORY.to_le_bytes())?;
self.write_all(&0u16.to_le_bytes())?; self.write_all(&0u16.to_le_bytes())?; self.write_all(&eocd_count.to_le_bytes())?;
self.write_all(&eocd_count.to_le_bytes())?;
self.write_all(&eocd_directory_size.to_le_bytes())?;
self.write_all(&eocd_directory_offset.to_le_bytes())?;
self.write_all(&0u16.to_le_bytes())?;
self.sink.flush().map_err(|error| {
self.poisoned = true;
fault(format!("the archive could not be flushed: {error}"))
})?;
Ok(self.sink.into_inner())
}
fn write_all(&mut self, bytes: &[u8]) -> Result<(), GedcomError> {
self.sink.write_all(bytes).map_err(|error| {
self.poisoned = true;
fault(format!("the archive could not be written: {error}"))
})
}
}
struct CountingWriter<W: Write> {
inner: W,
position: u64,
}
impl<W: Write> CountingWriter<W> {
const fn new(inner: W) -> Self {
Self { inner, position: 0 }
}
fn into_inner(self) -> W {
self.inner
}
}
impl<W: Write> Write for CountingWriter<W> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let written = self.inner.write(buf)?;
self.position = self
.position
.saturating_add(u64::try_from(written).unwrap_or(u64::MAX));
Ok(written)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
fn read_bounded<R: Read>(source: &mut R, limit: u64, name: &str) -> Result<Vec<u8>, GedcomError> {
let capacity = usize::try_from(limit.min(16 * 1024 * 1024)).unwrap_or(usize::MAX);
let mut buffer = Vec::with_capacity(capacity);
source
.take(limit.saturating_add(1))
.read_to_end(&mut buffer)
.map_err(|failure| fault(format!("{name} could not be read: {failure}")))?;
if u64::try_from(buffer.len()).unwrap_or(u64::MAX) > limit {
return Err(fault(format!(
"{name} is larger than the {limit} byte per-entry limit"
)));
}
Ok(buffer)
}
fn should_deflate(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
[".ged", ".txt", ".xml", ".json", ".csv", ".md", ".svg"]
.iter()
.any(|extension| lower.ends_with(extension))
}
pub fn validate_entry_path(name: &str) -> Result<(), GedcomError> {
if name.is_empty() {
return Err(fault("the archive contains a file with no name"));
}
if name.len() > 4096 {
return Err(fault(
"the archive contains a file with an unreasonably long path",
));
}
if name.starts_with('/') || name.starts_with('\\') {
return Err(fault(format!("{name} is an absolute path")));
}
if name.contains('\\') {
return Err(fault(format!(
"{name} uses a backslash, which a ZIP path may not"
)));
}
if name.chars().any(char::is_control) {
return Err(fault(
"the archive contains a control character in a file path",
));
}
if name
.chars()
.any(|character| matches!(character, ':' | '<' | '>' | '"' | '|' | '?' | '*'))
{
return Err(fault(format!(
"{name} contains a character that is unsafe in a portable path"
)));
}
let path = name.strip_suffix('/').unwrap_or(name);
if path.is_empty() {
return Err(fault("the archive contains a directory with no name"));
}
for segment in path.split('/') {
if segment.is_empty() || segment == "." || segment == ".." {
return Err(fault(format!("{name} contains an unsafe path component")));
}
if segment.ends_with([' ', '.']) || is_windows_device_name(segment) {
return Err(fault(format!(
"{name} contains a path component that is unsafe on Windows"
)));
}
}
Ok(())
}
fn is_windows_device_name(segment: &str) -> bool {
let stem = segment.split('.').next().unwrap_or_default();
let upper = stem.to_ascii_uppercase();
matches!(
upper.as_str(),
"CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$"
) || upper
.strip_prefix("COM")
.or_else(|| upper.strip_prefix("LPT"))
.is_some_and(|number| matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"))
}
const fn is_symbolic_link(external_attributes: u32) -> bool {
(external_attributes >> 16) & 0xF000 == 0xA000
}
fn inflate(data: &[u8], expected: u64, limit: u64, name: &str) -> Result<Vec<u8>, GedcomError> {
let ceiling = expected.min(limit);
let capacity = usize::try_from(ceiling).unwrap_or(usize::MAX);
let mut output = Vec::with_capacity(capacity.min(16 * 1024 * 1024));
let mut decoder = DeflateDecoder::new(data).take(ceiling.saturating_add(1));
decoder
.read_to_end(&mut output)
.map_err(|failure| fault(format!("{name} could not be decompressed: {failure}")))?;
if u64::try_from(output.len()).unwrap_or(u64::MAX) > ceiling {
return Err(fault(format!(
"{name} expands past the size the archive declared"
)));
}
Ok(output)
}
fn locate_directory(bytes: &[u8]) -> Result<(u64, usize), GedcomError> {
let search_start = bytes.len().saturating_sub(66 * 1024);
let position = (search_start..bytes.len().saturating_sub(21))
.rev()
.find(|index| read_u32(bytes, *index).is_ok_and(|value| value == END_OF_CENTRAL_DIRECTORY))
.ok_or_else(|| fault("the file does not end like a ZIP archive"))?;
let mut count = usize::from(read_u16(bytes, position + 10)?);
let mut offset = u64::from(read_u32(bytes, position + 16)?);
if count == usize::from(u16::MAX) || offset == u64::from(NEEDS_ZIP64) {
let (zip64_count, zip64_offset) = read_zip64_directory(bytes, position)?;
count = zip64_count;
offset = zip64_offset;
}
Ok((offset, count))
}
fn read_zip64_directory(bytes: &[u8], eocd: usize) -> Result<(usize, u64), GedcomError> {
let locator = eocd
.checked_sub(20)
.ok_or_else(|| fault("the archive needs a Zip64 directory and has none"))?;
if read_u32(bytes, locator)? != ZIP64_LOCATOR {
return Err(fault("the archive needs a Zip64 directory and has none"));
}
let record = usize::try_from(read_u64(bytes, locator + 8)?)
.map_err(|_| fault("the archive's Zip64 directory is beyond the end of the file"))?;
if read_u32(bytes, record)? != ZIP64_END_OF_CENTRAL_DIRECTORY {
return Err(fault("the archive's Zip64 directory record is malformed"));
}
let count = usize::try_from(read_u64(bytes, record + 32)?)
.map_err(|_| fault("the archive claims more files than can be counted"))?;
let offset = read_u64(bytes, record + 48)?;
Ok((count, offset))
}
fn read_zip64_extra(extra: &[u8], size: &mut u64, compressed: &mut u64, offset: &mut u64) {
let mut cursor = 0;
while cursor + 4 <= extra.len() {
let Ok(id) = read_u16(extra, cursor) else {
return;
};
let Ok(length) = read_u16(extra, cursor + 2) else {
return;
};
let body = cursor + 4;
let length = usize::from(length);
if id == ZIP64_EXTRA_FIELD {
let marker = u64::from(NEEDS_ZIP64);
let end = body + length;
let mut field = body;
let mut take = |slot: &mut u64| {
if *slot == marker && field + 8 <= end {
if let Ok(value) = read_u64(extra, field) {
*slot = value;
}
field += 8;
}
};
take(size);
take(compressed);
take(offset);
return;
}
cursor = body + length;
}
}
fn zip64_field(value: u64) -> (u32, Option<u64>) {
match u32::try_from(value) {
Ok(small) if small < NEEDS_ZIP64 => (small, None),
_ => (NEEDS_ZIP64, Some(value)),
}
}
fn zip64_extra(fields: &[Option<u64>]) -> Vec<u8> {
let payload: Vec<u8> = fields
.iter()
.filter_map(|field| *field)
.flat_map(u64::to_le_bytes)
.collect();
if payload.is_empty() {
return Vec::new();
}
let mut extra = Vec::with_capacity(4 + payload.len());
extra.extend_from_slice(&ZIP64_EXTRA_FIELD.to_le_bytes());
extra.extend_from_slice(
&u16::try_from(payload.len())
.unwrap_or(u16::MAX)
.to_le_bytes(),
);
extra.extend_from_slice(&payload);
extra
}
fn local_zip64_sizes(uncompressed_size: u64, compressed_size: u64) -> (u32, u32, Vec<u8>) {
let (size_field, size_zip64) = zip64_field(uncompressed_size);
let (compressed_field, compressed_zip64) = zip64_field(compressed_size);
if size_zip64.is_some() || compressed_zip64.is_some() {
(
NEEDS_ZIP64,
NEEDS_ZIP64,
zip64_extra(&[Some(uncompressed_size), Some(compressed_size)]),
)
} else {
(size_field, compressed_field, Vec::new())
}
}
#[allow(clippy::too_many_arguments, reason = "one ZIP record, one call site")]
fn central_record_bytes(
name: &str,
name_bytes: &[u8],
name_length: u16,
compression: u16,
checksum: u32,
size_field: u32,
compressed_field: u32,
size_zip64: Option<u64>,
compressed_zip64: Option<u64>,
local_offset: u64,
) -> Vec<u8> {
let (offset_field, offset_zip64) = zip64_field(local_offset);
let central_extra = zip64_extra(&[size_zip64, compressed_zip64, offset_zip64]);
let central_version = if central_extra.is_empty() {
PLAIN_VERSION_NEEDED
} else {
ZIP64_VERSION_NEEDED
};
let external_attributes = if name.ends_with('/') {
MS_DOS_DIRECTORY_ATTRIBUTE
} else {
0
};
let mut record = Vec::with_capacity(46 + name_bytes.len() + central_extra.len());
record.extend_from_slice(&CENTRAL_HEADER.to_le_bytes());
record.extend_from_slice(¢ral_version.to_le_bytes()); record.extend_from_slice(¢ral_version.to_le_bytes()); record.extend_from_slice(&UTF8_NAME_FLAG.to_le_bytes());
record.extend_from_slice(&compression.to_le_bytes());
record.extend_from_slice(&0u16.to_le_bytes()); record.extend_from_slice(&0x21u16.to_le_bytes()); record.extend_from_slice(&checksum.to_le_bytes());
record.extend_from_slice(&compressed_field.to_le_bytes());
record.extend_from_slice(&size_field.to_le_bytes());
record.extend_from_slice(&name_length.to_le_bytes());
record.extend_from_slice(
&u16::try_from(central_extra.len())
.unwrap_or(u16::MAX)
.to_le_bytes(),
);
record.extend_from_slice(&0u16.to_le_bytes()); record.extend_from_slice(&0u16.to_le_bytes()); record.extend_from_slice(&0u16.to_le_bytes()); record.extend_from_slice(&external_attributes.to_le_bytes());
record.extend_from_slice(&offset_field.to_le_bytes());
record.extend_from_slice(name_bytes);
record.extend_from_slice(¢ral_extra);
record
}
fn slice(bytes: &[u8], start: usize, length: usize) -> Result<&[u8], GedcomError> {
bytes
.get(start..start.saturating_add(length))
.ok_or_else(|| fault("the archive is truncated"))
}
fn checked_offset(start: usize, length: usize) -> Result<usize, GedcomError> {
start
.checked_add(length)
.ok_or_else(|| fault("an archive offset is too large"))
}
fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, GedcomError> {
let field: [u8; 2] = slice(bytes, offset, 2)?
.try_into()
.map_err(|_| fault("the archive is truncated"))?;
Ok(u16::from_le_bytes(field))
}
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, GedcomError> {
let field: [u8; 4] = slice(bytes, offset, 4)?
.try_into()
.map_err(|_| fault("the archive is truncated"))?;
Ok(u32::from_le_bytes(field))
}
fn read_u64(bytes: &[u8], offset: usize) -> Result<u64, GedcomError> {
let field: [u8; 8] = slice(bytes, offset, 8)?
.try_into()
.map_err(|_| fault("the archive is truncated"))?;
Ok(u64::from_le_bytes(field))
}
fn fault(message: impl Into<String>) -> GedcomError {
crate::fault(0, GedcomErrorKind::Archive, message)
}
#[cfg(test)]
mod tests {
use super::*;
fn archive_of(entries: &[(&str, &[u8])]) -> Vec<u8> {
let owned: Vec<(String, Vec<u8>)> = entries
.iter()
.map(|(name, content)| ((*name).to_owned(), (*content).to_vec()))
.collect();
write_archive(&owned).expect("write archive")
}
struct RemoveOnDrop<'a>(&'a std::path::Path);
impl Drop for RemoveOnDrop<'_> {
fn drop(&mut self) {
let _ = std::fs::remove_file(self.0);
}
}
struct Counting {
remaining: u64,
next: u8,
}
impl Read for Counting {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let want = usize::try_from(self.remaining.min(u64::try_from(buf.len()).unwrap()))
.unwrap_or(buf.len());
for slot in &mut buf[..want] {
*slot = self.next;
self.next = self.next.wrapping_add(1);
}
self.remaining -= u64::try_from(want).unwrap_or(0);
Ok(want)
}
}
#[test]
fn an_archive_written_here_reads_back_unchanged() {
let document = b"0 HEAD\n1 GEDC\n2 VERS 7.0\n0 TRLR\n";
let image = vec![0xFFu8; 4096];
let bytes = archive_of(&[("gedcom.ged", document), ("media/photo.jpg", &image)]);
assert!(Archive::looks_like_archive(&bytes));
let archive = Archive::open(&bytes).expect("open archive");
assert_eq!(archive.entries().len(), 2);
assert_eq!(archive.document_name().expect("document"), "gedcom.ged");
assert_eq!(archive.read("gedcom.ged").expect("read"), document);
assert_eq!(archive.read("media/photo.jpg").expect("read"), image);
}
#[test]
fn the_document_is_found_under_a_name_the_specification_did_not_choose() {
let bytes = archive_of(&[("family.ged", b"0 HEAD\n0 TRLR\n"), ("media/a.jpg", b"x")]);
let archive = Archive::open(&bytes).expect("open");
assert_eq!(archive.document_name().expect("document"), "family.ged");
}
#[test]
fn the_specified_name_wins_when_the_archive_holds_two() {
let bytes = archive_of(&[
("other.ged", b"0 HEAD\n0 TRLR\n"),
("gedcom.ged", b"0 HEAD\n0 TRLR\n"),
]);
let archive = Archive::open(&bytes).expect("open");
assert_eq!(archive.document_name().expect("document"), "gedcom.ged");
}
#[test]
fn two_documents_and_no_specified_name_is_ambiguous_rather_than_guessed() {
let bytes = archive_of(&[("a.ged", b"0 HEAD\n"), ("b.ged", b"0 HEAD\n")]);
let archive = Archive::open(&bytes).expect("open");
let failure = archive.document_name().expect_err("must refuse");
assert!(failure.to_string().contains("ambiguous"), "{failure}");
}
#[test]
fn an_archive_with_no_document_is_not_a_gedzip() {
let bytes = archive_of(&[("media/a.jpg", b"x")]);
let archive = Archive::open(&bytes).expect("open");
assert!(archive.document_name().is_err());
}
#[test]
fn a_path_that_climbs_out_of_the_folder_is_refused() {
for path in [
"../escape.jpg",
"media/../../escape.jpg",
"/absolute.jpg",
"media\\windows.jpg",
"C:/drive.jpg",
"media/file.txt:stream",
"media/NUL.txt",
"media/trailing. ",
"media//empty.jpg",
"media/./alias.jpg",
] {
let failure = validate_entry_path(path).expect_err(path);
assert!(!failure.to_string().is_empty(), "{path}");
}
validate_entry_path("media/ok.jpg").expect("ordinary path");
validate_entry_path("media/ok/").expect("ordinary directory path");
}
#[test]
fn a_hostile_path_is_refused_at_open_before_anything_is_extracted() {
let mut bytes = archive_of(&[("gedcom.ged", b"0 HEAD\n"), ("media/ok.jpg", b"x")]);
let position = bytes
.windows(12)
.position(|window| window == b"media/ok.jpg")
.expect("path present");
bytes[position..position + 12].copy_from_slice(b"../../ok.jpg");
let second = bytes
.windows(12)
.rposition(|window| window == b"media/ok.jpg")
.expect("second copy");
bytes[second..second + 12].copy_from_slice(b"../../ok.jpg");
let failure = Archive::open(&bytes).expect_err("must refuse");
assert!(failure.to_string().contains("unsafe path"), "{failure}");
}
#[test]
#[allow(
clippy::needless_collect,
reason = "positions must be collected before the byte buffer is mutated"
)]
fn a_hostile_directory_path_is_also_refused_at_open() {
let mut bytes = archive_of(&[("gedcom.ged", b"0 HEAD\n"), ("media/x/", b"")]);
for position in (0..bytes.len().saturating_sub(8))
.filter(|position| &bytes[*position..*position + 8] == b"media/x/")
.collect::<Vec<_>>()
{
bytes[position..position + 8].copy_from_slice(b"../../x/");
}
let failure = Archive::open(&bytes).expect_err("must refuse");
assert!(failure.to_string().contains("unsafe path"), "{failure}");
}
#[test]
fn the_writer_refuses_paths_that_collide_on_windows() {
let entries = [
("GEDCOM.GED".to_owned(), b"0 HEAD\n".to_vec()),
("gedcom.ged".to_owned(), b"0 TRLR\n".to_vec()),
];
let failure = write_archive(&entries).expect_err("must refuse");
assert!(failure.to_string().contains("more than once"), "{failure}");
}
#[test]
#[allow(
clippy::needless_collect,
reason = "positions must be collected before the byte buffer is mutated"
)]
fn the_reader_refuses_paths_that_collide_on_windows() {
let mut bytes = archive_of(&[("alpha.ged", b"0 HEAD\n"), ("bravo.ged", b"0 TRLR\n")]);
for position in (0..bytes.len().saturating_sub(9))
.filter(|position| &bytes[*position..*position + 9] == b"bravo.ged")
.collect::<Vec<_>>()
{
bytes[position..position + 9].copy_from_slice(b"ALPHA.ged");
}
let failure = Archive::open(&bytes).expect_err("must refuse");
assert!(failure.to_string().contains("more than once"), "{failure}");
}
#[test]
fn a_local_name_that_disagrees_with_the_directory_is_refused() {
let mut bytes = archive_of(&[("gedcom.ged", b"0 HEAD\n0 TRLR\n")]);
let position = bytes
.windows(10)
.position(|window| window == b"gedcom.ged")
.expect("local name");
bytes[position..position + 10].copy_from_slice(b"otherx.ged");
let archive = Archive::open(&bytes).expect("directory still opens");
let failure = archive.read("gedcom.ged").expect_err("must refuse");
assert!(failure.to_string().contains("local name"), "{failure}");
}
#[test]
fn directory_entries_cannot_be_read_as_files() {
let bytes = archive_of(&[("gedcom.ged", b"0 HEAD\n"), ("media/", b"")]);
let archive = Archive::open(&bytes).expect("open");
let directory = archive
.entries()
.iter()
.find(|entry| entry.is_directory)
.expect("directory");
let failure = archive.read_entry(directory).expect_err("must refuse");
assert!(failure.to_string().contains("directory"), "{failure}");
}
#[test]
fn an_entry_count_over_the_limit_is_refused() {
let bytes = archive_of(&[
("gedcom.ged", b"0 HEAD\n"),
("a.jpg", b"x"),
("b.jpg", b"y"),
]);
let limits = ArchiveLimits {
entries: 2,
..ArchiveLimits::DEFAULT
};
let failure = Archive::open_with(&bytes, limits).expect_err("must refuse");
assert!(failure.to_string().contains("limit is 2"), "{failure}");
}
#[test]
fn a_ratio_bomb_is_refused_before_it_is_decompressed() {
let bytes = archive_of(&[("bomb.ged", &vec![b'0'; 2 * 1024 * 1024])]);
let limits = ArchiveLimits {
ratio: 10,
..ArchiveLimits::DEFAULT
};
let failure = Archive::open_with(&bytes, limits).expect_err("must refuse");
assert!(failure.to_string().contains("fold"), "{failure}");
}
#[test]
fn an_entry_larger_than_the_limit_is_refused() {
let bytes = archive_of(&[("big.jpg", &vec![7u8; 1024])]);
let limits = ArchiveLimits {
entry_bytes: 512,
..ArchiveLimits::DEFAULT
};
let failure = Archive::open_with(&bytes, limits).expect_err("must refuse");
assert!(failure.to_string().contains("per-file limit"), "{failure}");
}
#[test]
fn a_corrupted_entry_fails_its_checksum_rather_than_being_written_out() {
let mut bytes = archive_of(&[("gedcom.ged", b"0 HEAD\n0 TRLR\n"), ("m.jpg", b"abcdefgh")]);
let position = bytes
.windows(8)
.position(|window| window == b"abcdefgh")
.expect("stored bytes present");
bytes[position] = b'z';
let archive = Archive::open(&bytes).expect("open");
let failure = archive.read("m.jpg").expect_err("must refuse");
assert!(failure.to_string().contains("checksum"), "{failure}");
}
#[test]
fn a_write_of_exactly_the_entry_count_sentinel_is_refused() {
let entries: Vec<(String, Vec<u8>)> = (0..u32::from(u16::MAX))
.map(|index| (format!("m/{index}"), Vec::new()))
.collect();
assert_eq!(entries.len(), usize::from(u16::MAX));
let failure =
write_archive(&entries).expect_err("exactly the sentinel count must be refused");
assert!(failure.to_string().contains("more files"), "{failure}");
}
#[test]
fn a_write_of_one_fewer_than_the_entry_count_sentinel_succeeds() {
let total = usize::from(u16::MAX) - 1;
let entries: Vec<(String, Vec<u8>)> = (0..total)
.map(|index| (format!("m/{index}"), Vec::new()))
.collect();
let bytes =
write_archive(&entries).expect("one fewer than the sentinel count must succeed");
let archive = Archive::open(&bytes).expect("gedcomkit's own reader must accept it");
assert_eq!(archive.entries().len(), total);
}
#[test]
fn a_write_of_more_entries_than_a_plain_zip_can_even_count_is_refused() {
let entries: Vec<(String, Vec<u8>)> = (0..=u32::from(u16::MAX))
.map(|index| (format!("m/{index}"), Vec::new()))
.collect();
let failure = write_archive(&entries).expect_err("must refuse");
assert!(failure.to_string().contains("more files"), "{failure}");
}
#[test]
fn plain_u32_field_refuses_only_at_and_past_the_zip64_sentinel() {
let below_sentinel = usize::try_from(NEEDS_ZIP64 - 1).unwrap();
assert_eq!(
plain_u32_field(below_sentinel, || "unexpected".to_owned()).unwrap(),
NEEDS_ZIP64 - 1
);
let sentinel = usize::try_from(NEEDS_ZIP64).unwrap();
assert!(plain_u32_field(sentinel, || "at the sentinel".to_owned()).is_err());
if let Ok(past_u32) = usize::try_from(u64::from(NEEDS_ZIP64) + 1) {
assert!(plain_u32_field(past_u32, || "past u32".to_owned()).is_err());
}
}
#[test]
fn plain_u16_field_refuses_only_at_and_past_the_entry_count_sentinel() {
assert_eq!(
plain_u16_field(usize::from(u16::MAX) - 1, || "unexpected".to_owned()).unwrap(),
u16::MAX - 1
);
assert!(plain_u16_field(usize::from(u16::MAX), || "at the sentinel".to_owned()).is_err());
assert!(plain_u16_field(usize::from(u16::MAX) + 1, || "past u16".to_owned()).is_err());
}
#[test]
fn something_that_is_not_an_archive_is_reported_as_such() {
assert!(!Archive::looks_like_archive(b"0 HEAD\n"));
assert!(Archive::open(b"0 HEAD\n0 TRLR\n").is_err());
}
#[test]
fn the_streaming_writer_round_trips_stored_and_deflated_entries() {
let document = b"0 HEAD\n1 GEDC\n2 VERS 7.0\n0 TRLR\n";
let image = vec![0xFFu8; 4096];
let mut writer = ArchiveWriter::new(Vec::new());
writer
.add("gedcom.ged", &document[..])
.expect("add document");
writer
.add("media/photo.jpg", image.as_slice())
.expect("add media");
let bytes = writer.finish().expect("finish");
assert!(Archive::looks_like_archive(&bytes));
let archive = Archive::open(&bytes).expect("open archive");
assert_eq!(archive.entries().len(), 2);
assert_eq!(archive.document_name().expect("document"), "gedcom.ged");
assert_eq!(archive.read("gedcom.ged").expect("read"), document);
assert_eq!(archive.read("media/photo.jpg").expect("read"), image);
assert_eq!(
archive.total_size(),
u64::try_from(document.len() + image.len()).unwrap()
);
let one_shot = write_archive(&[
("gedcom.ged".to_owned(), document.to_vec()),
("media/photo.jpg".to_owned(), image.clone()),
])
.expect("one-shot write");
assert_eq!(bytes, one_shot);
}
#[test]
fn the_writer_refuses_an_entry_larger_than_its_limit() {
let limits = ArchiveLimits {
entry_bytes: 512,
..ArchiveLimits::DEFAULT
};
let mut writer = ArchiveWriter::new_with(Vec::new(), limits);
let failure = writer
.add("big.jpg", &vec![7u8; 1024][..])
.expect_err("must refuse");
assert!(failure.to_string().contains("per-entry limit"), "{failure}");
}
#[test]
fn a_rejected_entry_does_not_count_toward_the_finished_archive() {
let limits = ArchiveLimits {
entry_bytes: 4,
..ArchiveLimits::DEFAULT
};
let mut writer = ArchiveWriter::new_with(Vec::new(), limits);
writer.add("a.jpg", &b"ok"[..]).expect("small entry");
writer
.add("big.jpg", &b"way too much data"[..])
.expect_err("must refuse for size");
writer
.add("big.jpg", &b"ok"[..])
.expect("the rejected name can be retried");
let bytes = writer.finish().expect("finish");
let archive = Archive::open(&bytes).expect("open");
assert_eq!(archive.entries().len(), 2);
assert_eq!(archive.read("big.jpg").expect("read"), b"ok");
}
#[test]
fn the_writer_refuses_more_entries_than_its_limit() {
let limits = ArchiveLimits {
entries: 2,
..ArchiveLimits::DEFAULT
};
let mut writer = ArchiveWriter::new_with(Vec::new(), limits);
writer.add("a.jpg", &b"x"[..]).expect("first entry");
writer.add("b.jpg", &b"y"[..]).expect("second entry");
let failure = writer.add("c.jpg", &b"z"[..]).expect_err("must refuse");
assert!(failure.to_string().contains("limit is 2"), "{failure}");
}
#[test]
fn the_writer_refuses_a_repeated_path() {
let mut writer = ArchiveWriter::new(Vec::new());
writer.add("a.ged", &b"0 HEAD\n"[..]).expect("first");
let failure = writer
.add("A.GED", &b"0 TRLR\n"[..])
.expect_err("must refuse");
assert!(failure.to_string().contains("more than once"), "{failure}");
}
#[test]
fn the_writer_refuses_an_unsafe_path() {
let mut writer = ArchiveWriter::new(Vec::new());
let failure = writer
.add("../escape.jpg", &b"x"[..])
.expect_err("must refuse");
assert!(!failure.to_string().is_empty(), "{failure}");
}
fn local_header_flags(bytes: &[u8], header_start: usize) -> u16 {
u16::from_le_bytes(
bytes[header_start + 6..header_start + 8]
.try_into()
.unwrap(),
)
}
fn central_record_flags(bytes: &[u8], record_start: usize) -> u16 {
u16::from_le_bytes(
bytes[record_start + 8..record_start + 10]
.try_into()
.unwrap(),
)
}
fn central_record_external_attributes(bytes: &[u8], record_start: usize) -> u32 {
u32::from_le_bytes(
bytes[record_start + 38..record_start + 42]
.try_into()
.unwrap(),
)
}
#[test]
fn entries_are_written_with_the_utf8_name_flag_set() {
let bytes = archive_of(&[("gedcom.ged", b"0 HEAD\n0 TRLR\n")]);
assert_eq!(&bytes[0..4], &LOCAL_HEADER.to_le_bytes());
assert_eq!(
local_header_flags(&bytes, 0) & UTF8_NAME_FLAG,
UTF8_NAME_FLAG
);
let central = bytes
.windows(4)
.position(|window| window == CENTRAL_HEADER.to_le_bytes())
.expect("central directory record");
assert_eq!(
central_record_flags(&bytes, central) & UTF8_NAME_FLAG,
UTF8_NAME_FLAG
);
let mut writer = ArchiveWriter::new(Vec::new());
writer
.add("gedcom.ged", &b"0 HEAD\n0 TRLR\n"[..])
.expect("add");
let streamed = writer.finish().expect("finish");
assert_eq!(&streamed[0..4], &LOCAL_HEADER.to_le_bytes());
assert_eq!(
local_header_flags(&streamed, 0) & UTF8_NAME_FLAG,
UTF8_NAME_FLAG
);
let streamed_central = streamed
.windows(4)
.position(|window| window == CENTRAL_HEADER.to_le_bytes())
.expect("central directory record");
assert_eq!(
central_record_flags(&streamed, streamed_central) & UTF8_NAME_FLAG,
UTF8_NAME_FLAG
);
}
#[test]
fn non_ascii_names_round_trip_with_the_utf8_flag_on_every_local_header() {
let mut writer = ArchiveWriter::new(Vec::new());
writer
.add("gedcom.ged", &b"0 HEAD\n0 TRLR\n"[..])
.expect("add document");
writer
.add("m\u{e9}dia/Gro\u{df}vater.jpg", &[1u8, 2, 3][..])
.expect("add accented name");
writer
.add("media/\u{65e5}\u{672c}.jpg", &[4u8, 5][..])
.expect("add CJK name");
let bytes = writer.finish().expect("finish");
let archive = Archive::open(&bytes).expect("open");
let names: Vec<&str> = archive.entries().iter().map(|e| e.name.as_str()).collect();
assert_eq!(
names,
[
"gedcom.ged",
"m\u{e9}dia/Gro\u{df}vater.jpg",
"media/\u{65e5}\u{672c}.jpg"
]
);
let mut cursor = 0usize;
let mut local_headers = 0;
while let Some(offset) = bytes[cursor..]
.windows(4)
.position(|window| window == LOCAL_HEADER.to_le_bytes())
{
let position = cursor + offset;
assert_eq!(
local_header_flags(&bytes, position) & UTF8_NAME_FLAG,
UTF8_NAME_FLAG,
"local header at {position}"
);
local_headers += 1;
cursor = position + 4;
}
assert_eq!(local_headers, 3);
}
#[test]
fn a_sink_write_failure_poisons_the_writer() {
#[derive(Debug)]
struct FlakySink {
calls: usize,
fail_on: usize,
}
impl Write for FlakySink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.calls += 1;
if self.calls == self.fail_on {
return Err(std::io::Error::other("disk full (simulated)"));
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut writer = ArchiveWriter::new(FlakySink {
calls: 0,
fail_on: 3,
});
let failure = writer.add("a.jpg", &b"aaaa"[..]).expect_err("must fail");
assert!(!failure.to_string().is_empty(), "{failure}");
let retry = writer
.add("b.jpg", &b"bbbb"[..])
.expect_err("a poisoned writer must refuse further entries");
assert!(retry.to_string().contains("previous write"), "{retry}");
let finished = writer
.finish()
.expect_err("a poisoned writer cannot be finished");
assert!(
finished.to_string().contains("previous write"),
"{finished}"
);
}
#[test]
fn the_writer_implements_debug_without_requiring_it_of_the_sink() {
let writer = ArchiveWriter::new(Vec::new());
let text = format!("{writer:?}");
assert!(text.contains("ArchiveWriter"), "{text}");
}
#[test]
fn directory_entries_carry_the_ms_dos_directory_attribute() {
let bytes = archive_of(&[("gedcom.ged", b"0 HEAD\n"), ("media/", b"")]);
let central = bytes
.windows(4)
.rposition(|window| window == CENTRAL_HEADER.to_le_bytes())
.expect("a central directory record for the directory entry");
assert_eq!(
central_record_external_attributes(&bytes, central),
MS_DOS_DIRECTORY_ATTRIBUTE
);
let mut writer = ArchiveWriter::new(Vec::new());
writer
.add("gedcom.ged", &b"0 HEAD\n"[..])
.expect("add document");
writer.add("media/", &b""[..]).expect("add directory");
let streamed = writer.finish().expect("finish");
let streamed_central = streamed
.windows(4)
.rposition(|window| window == CENTRAL_HEADER.to_le_bytes())
.expect("a central directory record for the directory entry");
assert_eq!(
central_record_external_attributes(&streamed, streamed_central),
MS_DOS_DIRECTORY_ATTRIBUTE
);
}
#[test]
fn the_writer_promotes_to_zip64_once_the_entry_count_overflows_a_plain_zip() {
let total = usize::from(u16::MAX) + 4;
let mut writer = ArchiveWriter::new(Vec::new());
for index in 0..total {
writer
.add(&format!("m/{index}"), &b""[..])
.unwrap_or_else(|error| panic!("add {index}: {error}"));
}
let bytes = writer.finish().expect("finish");
assert!(
bytes
.windows(4)
.any(|window| window == ZIP64_END_OF_CENTRAL_DIRECTORY.to_le_bytes()),
"no Zip64 end-of-central-directory record was written"
);
assert!(
bytes
.windows(4)
.any(|window| window == ZIP64_LOCATOR.to_le_bytes()),
"no Zip64 locator was written"
);
let archive = Archive::open(&bytes).expect("open a Zip64 archive");
assert_eq!(archive.entries().len(), total);
assert_eq!(archive.total_size(), 0);
assert_eq!(archive.read("m/0").expect("first entry"), b"");
assert_eq!(
archive
.read(&format!("m/{}", total - 1))
.expect("last entry"),
b""
);
}
#[test]
fn the_writer_promotes_to_zip64_at_exactly_the_entry_count_sentinel() {
let total = usize::from(u16::MAX);
let mut writer = ArchiveWriter::new(Vec::new());
for index in 0..total {
writer
.add(&format!("m/{index}"), &b""[..])
.unwrap_or_else(|error| panic!("add {index}: {error}"));
}
let bytes = writer.finish().expect("finish");
assert!(
bytes
.windows(4)
.any(|window| window == ZIP64_END_OF_CENTRAL_DIRECTORY.to_le_bytes()),
"no Zip64 end-of-central-directory record was written for exactly {total} entries"
);
let archive =
Archive::open(&bytes).expect("gedcomkit's own reader must accept its own archive");
assert_eq!(archive.entries().len(), total);
}
#[test]
fn zip64_field_marks_only_values_that_overflow_a_32_bit_slot() {
assert_eq!(zip64_field(0), (0, None));
assert_eq!(zip64_field(u64::from(u32::MAX) - 1), (u32::MAX - 1, None));
assert_eq!(
zip64_field(u64::from(u32::MAX)),
(NEEDS_ZIP64, Some(u64::from(u32::MAX)))
);
let huge = u64::from(u32::MAX) + 1;
assert_eq!(zip64_field(huge), (NEEDS_ZIP64, Some(huge)));
}
#[test]
fn zip64_extra_encodes_only_the_fields_that_actually_overflowed() {
assert_eq!(zip64_extra(&[None, None, None]), Vec::<u8>::new());
let extra = zip64_extra(&[None, Some(9_876_543_210), None]);
assert_eq!(extra.len(), 4 + 8);
assert_eq!(u16::from_le_bytes([extra[0], extra[1]]), ZIP64_EXTRA_FIELD);
assert_eq!(u16::from_le_bytes([extra[2], extra[3]]), 8);
assert_eq!(
u64::from_le_bytes(extra[4..12].try_into().unwrap()),
9_876_543_210
);
let extra = zip64_extra(&[Some(1), Some(2), Some(3)]);
assert_eq!(extra.len(), 4 + 24);
assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap()), 1);
assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap()), 2);
assert_eq!(u64::from_le_bytes(extra[20..28].try_into().unwrap()), 3);
}
#[test]
fn local_zip64_sizes_carries_both_values_even_when_only_one_overflows() {
let uncompressed_size = u64::from(u32::MAX) + 1; let compressed_size = 100;
let (size_field, compressed_field, extra) =
local_zip64_sizes(uncompressed_size, compressed_size);
assert_eq!(size_field, NEEDS_ZIP64);
assert_eq!(
compressed_field, NEEDS_ZIP64,
"the field that did not overflow must still be promoted to the sentinel, so a \
reader resolves the extra's second value against the right field"
);
assert_eq!(
extra.len(),
4 + 16,
"both sizes must ride together in one 16-byte payload, not just the one that overflowed"
);
assert_eq!(
u16::from_le_bytes(extra[0..2].try_into().unwrap()),
ZIP64_EXTRA_FIELD
);
assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap()), 16);
assert_eq!(
u64::from_le_bytes(extra[4..12].try_into().unwrap()),
uncompressed_size
);
assert_eq!(
u64::from_le_bytes(extra[12..20].try_into().unwrap()),
compressed_size
);
}
#[test]
#[ignore = "writes just over 4 GiB to disk; run explicitly with --ignored"]
fn an_entry_past_four_gibibytes_of_offset_round_trips_through_zip64() {
let padding = u64::from(u32::MAX) + 4096; let path = std::env::temp_dir().join(format!(
"gedcomkit-zip64-offset-test-{}.gdz",
std::process::id()
));
let _cleanup = RemoveOnDrop(&path);
let limits = ArchiveLimits::DEFAULT.with_entry_bytes(padding + 1024);
let file = std::fs::File::create(&path).expect("create temp file");
let mut writer = ArchiveWriter::new_with(file, limits);
writer
.add(
"media/padding.bin",
Counting {
remaining: padding,
next: 0,
},
)
.expect("add the padding entry");
writer
.add("media/after.bin", &b"past four gibibytes"[..])
.expect("add the entry past the 4 GiB offset");
drop(writer.finish().expect("finish"));
let bytes = std::fs::read(&path).expect("read the archive back");
let read_limits = ArchiveLimits::DEFAULT
.with_entry_bytes(padding + 1024)
.with_total_bytes(padding + 1024);
let archive = Archive::open_with(&bytes, read_limits)
.expect("open a Zip64 archive with an oversized offset");
assert_eq!(
archive
.read("media/after.bin")
.expect("read the late entry"),
b"past four gibibytes"
);
}
#[test]
#[ignore = "writes exactly 4 GiB to disk; run explicitly with --ignored"]
fn an_entry_of_exactly_the_zip64_size_sentinel_gets_a_zip64_extra() {
let size = u64::from(u32::MAX); let path = std::env::temp_dir().join(format!(
"gedcomkit-zip64-size-sentinel-test-{}.gdz",
std::process::id()
));
let _cleanup = RemoveOnDrop(&path);
let limits = ArchiveLimits::DEFAULT.with_entry_bytes(size + 1024);
let file = std::fs::File::create(&path).expect("create temp file");
let mut writer = ArchiveWriter::new_with(file, limits);
writer
.add(
"media/exact.bin",
Counting {
remaining: size,
next: 0,
},
)
.expect("add the exactly-sentinel-sized entry");
drop(writer.finish().expect("finish"));
let bytes = std::fs::read(&path).expect("read the archive back");
assert_eq!(&bytes[0..4], &LOCAL_HEADER.to_le_bytes());
assert_eq!(
u32::from_le_bytes(bytes[18..22].try_into().unwrap()),
NEEDS_ZIP64,
"the local header's compressed-size field must be the sentinel, not the bare size"
);
assert_eq!(
u32::from_le_bytes(bytes[22..26].try_into().unwrap()),
NEEDS_ZIP64,
"the local header's size field must be the sentinel, not the bare size"
);
let name_length = usize::from(u16::from_le_bytes(bytes[26..28].try_into().unwrap()));
let extra_length = usize::from(u16::from_le_bytes(bytes[28..30].try_into().unwrap()));
assert_eq!(name_length, "media/exact.bin".len());
let extra_start = 30 + name_length;
let extra = &bytes[extra_start..extra_start + extra_length];
assert_eq!(
u16::from_le_bytes(extra[0..2].try_into().unwrap()),
ZIP64_EXTRA_FIELD,
"an entry of exactly 0xFFFF_FFFF bytes must carry a Zip64 extra, not a bare sentinel"
);
assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap()), size);
assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap()), size);
let read_limits = ArchiveLimits::DEFAULT.with_entry_bytes(size + 1024);
let archive = Archive::open_with(&bytes, read_limits)
.expect("gedcomkit's own reader must accept its own archive");
assert_eq!(archive.entries()[0].size, size);
assert_eq!(archive.entries()[0].compressed_size, size);
}
#[test]
#[ignore = "writes just under 4 GiB to disk; run explicitly with --ignored"]
fn a_central_directory_starting_at_exactly_the_zip64_offset_sentinel_opens() {
let name = "media/padding.bin";
let local_header_len = 30 + u64::try_from(name.len()).unwrap();
let padding = u64::from(u32::MAX) - local_header_len; let path = std::env::temp_dir().join(format!(
"gedcomkit-zip64-directory-offset-sentinel-test-{}.gdz",
std::process::id()
));
let _cleanup = RemoveOnDrop(&path);
let limits = ArchiveLimits::DEFAULT.with_entry_bytes(padding + 1024);
let file = std::fs::File::create(&path).expect("create temp file");
let mut writer = ArchiveWriter::new_with(file, limits);
writer
.add(
name,
Counting {
remaining: padding,
next: 0,
},
)
.expect("add the entry that lands the directory exactly on the sentinel");
drop(writer.finish().expect("finish"));
let bytes = std::fs::read(&path).expect("read the archive back");
let read_limits = ArchiveLimits::DEFAULT
.with_entry_bytes(padding + 1024)
.with_total_bytes(padding + 1024);
let archive = Archive::open_with(&bytes, read_limits).expect(
"a central directory at exactly offset 0xFFFF_FFFF must open, not be refused for \
a missing Zip64 record that a correctly-promoted writer would have written",
);
assert_eq!(archive.entries().len(), 1);
assert_eq!(archive.entries()[0].name, name);
}
}