Files
craft-libs/crates/zip/tests/archive.rs
Dylan M. Taylorandskylar taylor-barrick 17e55c96db Add craft-zip: bounded ZIP reading and a deterministic writer for the package crates (#941)
* craft-zip: ZIP preflight, bounded entry read and Budget (from craft-archive, #129)

The ZIP half of the craft-archive proposal (#126, #129): an allocation-free check of the end
records and central directory that runs before a ZIP reader indexes the archive, a bounded
entry read that distrusts the declared size, and the named Budget both charge. The XML half
of #129 is not carried: craft-xml bounds XML now.

* craft-zip: Archive (entry-count guard, duplicate names, budgeted reads) and Writer

check_entry_count is craft-opc's guard: the declared entry count, ZIP64 included, checked on
the raw bytes before zip parses the central directory. Archive reads entries within a per-entry
limit and a total Budget through read_entry. Duplicate names are found by counting the central
directory headers against the entries zip kept. Writer is craft-opc's ZipOut: stored or deflated
entries, ZIP64 for large ones, one fixed timestamp.

---------

Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-11-11 18:53:53 -05:00

149 lines
6.8 KiB
Rust

//! Reading archives within limits, duplicate names, and the writer.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use craft_zip::{Archive, Compression, Error, Limits, Writer, check_entry_count, zip64_entry_count_sample};
const ROOMY: Limits = Limits { max_entries: 1000, max_total_bytes: 1 << 20 };
fn archive(entries: &[(&str, &[u8], Compression)]) -> Vec<u8> {
let mut w = Writer::new();
for (name, data, compression) in entries {
w.add(name, data, *compression).unwrap();
}
w.finish().unwrap()
}
fn find(bytes: &[u8], needle: &[u8], from: usize) -> usize {
from + bytes[from..].windows(needle.len()).position(|w| w == needle).unwrap()
}
#[test]
fn entries_are_written_in_order_and_read_back() {
let bytes = archive(&[
("mimetype", b"application/x-test", Compression::Stored),
("dir/a.xml", &[b'a'; 5000], Compression::Deflated),
("b.bin", b"\x00\x01", Compression::Level(9)),
]);
// A stored first entry is readable in place: local header (30) + name (8).
assert_eq!(&bytes[38..56], b"application/x-test");
assert!(bytes.len() < 1000, "the deflated entry is compressed");
let mut z = Archive::open(&bytes, ROOMY).unwrap();
assert_eq!(z.names().collect::<Vec<_>>(), ["mimetype", "dir/a.xml", "b.bin"]);
assert_eq!((z.len(), z.is_empty(), z.has_duplicate_names()), (3, false, false));
assert!(z.contains("b.bin") && !z.contains("B.bin"));
assert_eq!(z.declared_size("dir/a.xml"), Some(5000));
assert_eq!(z.declared_size("nope"), None);
assert_eq!(z.read("dir/a.xml", 5000).unwrap().unwrap(), vec![b'a'; 5000]);
assert_eq!(z.read("b.bin", 10).unwrap().unwrap(), [0, 1]);
assert_eq!(z.read("nope", 10).unwrap(), None);
assert_eq!(z.budget().used(), 5002);
}
#[test]
fn the_output_is_deterministic() {
let make = || archive(&[("a", b"one", Compression::Deflated), ("b", b"two", Compression::Stored)]);
assert_eq!(make(), make());
}
#[test]
fn adding_a_name_twice_is_an_error() {
let mut w = Writer::new();
w.add("a", b"1", Compression::Stored).unwrap();
assert!(matches!(w.add("a", b"2", Compression::Stored), Err(Error::Write(m)) if m.starts_with("a: ")));
}
#[test]
fn what_is_not_a_zip_is_refused() {
assert!(matches!(Archive::open(b"not a zip", ROOMY).err(), Some(Error::NotZip(_))));
assert!(matches!(Archive::open(b"", ROOMY).err(), Some(Error::NotZip(_))));
let bytes = archive(&[("a", b"abc", Compression::Stored)]);
assert!(Archive::open(&bytes[..bytes.len() - 5], ROOMY).is_err());
}
#[test]
fn an_entry_past_its_limit_is_refused_and_not_charged() {
let bytes = archive(&[("a", &[b'a'; 100], Compression::Deflated)]);
let mut z = Archive::open(&bytes, ROOMY).unwrap();
assert_eq!(z.read("a", 99), Err(Error::EntryTooLarge { name: "a".into(), limit: 99 }));
assert_eq!(z.budget().used(), 0);
assert_eq!(z.read("a", 100).unwrap().map(|d| d.len()), Some(100));
}
#[test]
fn entries_cannot_add_up_past_the_total() {
let data = [b'a'; 100];
let bytes = archive(&[("a", &data, Compression::Deflated), ("b", &data, Compression::Deflated), ("c", &data, Compression::Deflated)]);
let mut z = Archive::open(&bytes, Limits { max_total_bytes: 250, ..ROOMY }).unwrap();
assert!(z.read("a", 1000).unwrap().is_some());
assert!(z.read("b", 1000).unwrap().is_some());
assert_eq!(z.budget().remaining(), 50);
let refused = z.read("c", 1000);
assert_eq!(refused, Err(Error::BudgetSpent { name: "c".into(), limit: 250 }));
assert!(refused.is_err_and(|e| e.is_limit()));
// A caller with its own rule can charge more.
z.budget_mut().charge(50).unwrap();
assert_eq!(z.budget().remaining(), 0);
}
/// The size an entry declares is a claim. One that expands past it is refused whatever the
/// limit, and a small declared size does not get a large entry under a small limit.
#[test]
fn an_entry_that_expands_past_its_declared_size_is_refused() {
let mut bytes = archive(&[("a", &[b'a'; 5000], Compression::Deflated)]);
// The uncompressed size, in the central header (at 24) and the local header (at 22).
let central = find(&bytes, b"PK\x01\x02", 0);
for at in [central + 24, 22] {
assert_eq!(bytes[at..at + 4], 5000u32.to_le_bytes());
bytes[at..at + 4].copy_from_slice(&10u32.to_le_bytes());
}
let mut z = Archive::open(&bytes, ROOMY).unwrap();
assert_eq!(z.declared_size("a"), Some(10));
// `zip` notices first when it can; either way it is refused and nothing is charged.
assert!(matches!(z.read("a", 1 << 20), Err(Error::EntryTooLarge { limit: 10, .. } | Error::Entry { .. })));
assert_eq!(z.budget().used(), 0);
}
#[test]
fn a_broken_entry_is_an_entry_error() {
let mut bytes = archive(&[("a", b"hello world", Compression::Stored)]);
let at = find(&bytes, b"hello", 0);
bytes[at] = b'j';
let mut z = Archive::open(&bytes, ROOMY).unwrap();
assert!(matches!(z.read("a", 100), Err(Error::Entry { name, .. }) if name == "a"));
assert_eq!(z.budget().used(), 0);
}
#[test]
fn two_entries_with_one_name_are_told() {
let mut bytes = archive(&[("a", b"first", Compression::Stored), ("b", b"second", Compression::Stored)]);
assert!(!Archive::open(&bytes, ROOMY).unwrap().has_duplicate_names());
// Rename `b` to `a` in its local header and in the central directory.
let local = find(&bytes, b"PK\x03\x04", 4);
bytes[local + 30] = b'a';
let central = find(&bytes, b"PK\x01\x02", find(&bytes, b"PK\x01\x02", 0) + 4);
assert_eq!(bytes[central + 46], b'b');
bytes[central + 46] = b'a';
let z = Archive::open(&bytes, ROOMY).unwrap();
assert_eq!(z.len(), 1);
assert!(z.has_duplicate_names());
}
#[test]
fn an_archive_declaring_too_many_entries_is_refused_before_it_is_indexed() {
let bomb = zip64_entry_count_sample(20_001);
assert_eq!(check_entry_count(&bomb, 20_000), Err(Error::TooManyEntries { declared: 20_001, limit: 20_000 }));
assert_eq!(Archive::open(&bomb, Limits { max_entries: 20_000, ..ROOMY }).err(), Some(Error::TooManyEntries { declared: 20_001, limit: 20_000 }));
let bytes = archive(&[("a", b"", Compression::Stored), ("b", b"", Compression::Stored), ("c", b"", Compression::Stored)]);
assert_eq!(Archive::open(&bytes, Limits { max_entries: 2, ..ROOMY }).err(), Some(Error::TooManyEntries { declared: 3, limit: 2 }));
assert!(Archive::open(&bytes, Limits { max_entries: 3, ..ROOMY }).is_ok());
}
/// What the strict preflight accepts, the writer writes.
#[test]
fn written_archives_pass_the_strict_preflight() {
use craft_zip::preflight::{Limits, preflight};
let bytes = archive(&[("mimetype", b"x", Compression::Stored), ("a/b.xml", &[b'a'; 300], Compression::Deflated)]);
let directory = preflight(&bytes, Limits { max_entries: 10, max_central_directory_bytes: 1 << 16, max_name_bytes: 256 }).unwrap();
assert_eq!(directory.entries, 2);
}