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;
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
}
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)
}
}
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 = u32::try_from(output.len())
.map_err(|_| fault("the archive is larger than this writer can address"))?;
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 = u32::try_from(content.len())
.map_err(|_| fault(format!("{name} is too large for this writer")))?;
let compressed_size = u32::try_from(payload.len())
.map_err(|_| fault(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")))?;
output.extend_from_slice(&LOCAL_HEADER.to_le_bytes());
output.extend_from_slice(&20u16.to_le_bytes()); output.extend_from_slice(&0u16.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(&0u16.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(&0u32.to_le_bytes()); directory.extend_from_slice(&local_offset.to_le_bytes());
directory.extend_from_slice(name_bytes);
}
let directory_offset = u32::try_from(output.len())
.map_err(|_| fault("the archive is larger than this writer can address"))?;
let directory_size = u32::try_from(directory.len())
.map_err(|_| fault("the archive is larger than this writer can address"))?;
let count = u16::try_from(entries.len())
.map_err(|_| fault("the archive holds more files than this writer can address"))?;
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)
}
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 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")
}
#[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_more_entries_than_a_plain_zip_addresses_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 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());
}
}