mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 11:13:55 +00:00
* 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>
149 lines
6.8 KiB
Rust
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);
|
|
}
|