Files
craft-libs/crates/zip/README.md
Dylan M. Taylor 6f05ad84a3 Move craft-opc, -odf, -kra, -ora, -penpot, -fig and -sketch onto craft-zip (#995)
* craft-kra, craft-ora: read and write through craft-zip

Both drop their own bounded entry read (and craft-ora its zip writer). New: the declared entry
count is checked before the archive is indexed (MAX_ENTRIES, 100,000), and an archive with two
entries of one name is refused. craft-ora writes a fixed timestamp. zip_mimetype moves into
craft-zip as stored_mimetype; both crates keep their function.

* craft-zip: Archive is Clone; compressed_size and read_head

For the package crates moving onto it: a clone reads behind &self with its own count,
compressed_size gives a format the ratio an entry claims, and read_head reads a signature
without inflating the rest.

* craft-sketch: read through craft-zip

Its own budgets (ratio, per-member, JSON total, image total) stay. New: the declared entry
count is checked before the archive is indexed (MAX_ENTRIES, 100,000), also when sniffing, and
an archive with two entries of one name is refused.

* craft-fig: read and write through craft-zip

The image budget (per image, total, charged for failed reads, skipped count) stays this crate's.
A failed image read is now charged its declared size, which bounds it because the read stops one
byte past it; the separate checksum check at an exact budget goes, as the read now always reaches
the end of the entry. New: the declared entry count is checked before the archive is indexed
(MAX_ENTRIES, 1,000,000), also when sniffing, and two entries of one name are refused. The
writer writes a fixed timestamp.

* craft-penpot: read and write through craft-zip

The 1 GiB byte budget is now the archive's total in craft-zip, counted from bytes actually read;
the JSON value budget and the sticky exhaustion flag stay here. Duplicate names are found by
craft-zip (the hand-read end record goes). A read that does not fit what is left is refused
before it starts and fails every later check(); one that fails otherwise is charged its declared
size. embedded_fonts reads four bytes of each candidate with read_head. New: the entry count is
checked before the archive is indexed.

* craft-opc: read and write through craft-zip

The entry-count guard and its test sample move to craft-zip; check_entry_count and
zip64_entry_count_sample stay here as wrappers. Package and LazyPackage keep their own counts and
rules and read through craft-zip's bounded read. A part that declares more than is left is now
refused before it is inflated, so it no longer spends the inflate budget (the test that pinned
the old charge is updated). An exactly repeated entry name is logged, not refused. ZipOut wraps
craft-zip's Writer; its output is unchanged in kind (level 6, fixed timestamp).

* craft-odf: read and write through craft-zip

The entry-count guard moves to craft-zip; zip_guard::check and zip64_entry_count_sample stay
public here as wrappers. Package keeps its own count and reads through craft-zip's bounded
read: a damaged file is still Ok(None), now charged its declared size. Two files of one name
are refused. encrypt read every file whole and decrypt cut a file at the limit without saying
so; both now refuse a file declaring more than the per-file limit.

* craft-ora: a needless borrow in a test; Cargo.lock for the crates now on craft-zip
2026-11-11 19:48:05 -05:00

3.7 KiB

craft-zip

ZIP archives from untrusted files, read within limits and written. No unsafe.

The zip crate does the decoding. This crate is what every reader of a ZIP-based format (OOXML, OpenDocument, EPUB, IDML, design-tool archives) otherwise writes again around it. Written from the ZIP specification (PKWARE APPNOTE.TXT).

Every limit is the caller's number: the crate has no defaults. Entry-name rules (which names are safe, which spellings are one part) stay with the format that knows them.

Reading

use craft_zip::{Archive, Compression, Limits, Writer};

# let mut w = Writer::new();
# w.add("mimetype", b"application/epub+zip", Compression::Stored)?;
# w.add("content.xml", b"<a/>", Compression::Deflated)?;
# let bytes = w.finish()?;
let mut zip = Archive::open(&bytes, Limits { max_entries: 20_000, max_total_bytes: 512 << 20 })?;
if zip.has_duplicate_names() {
    // Two entries share a name; a format that must not guess which is real refuses here.
}
let part = zip.read("content.xml", 64 << 20)?;       // Ok(None) when there is no such entry
assert_eq!(part.as_deref(), Some(&b"<a/>"[..]));
assert_eq!(zip.budget().used(), 4);
# Ok::<(), craft_zip::Error>(())
  • Archive::open runs check_entry_count first. zip reserves room for, and parses, as many central directory headers as the end record declares before its caller can count them, and a ZIP64 end record can declare millions. The check finds the end records the way zip does and refuses on the raw bytes.
  • Archive::read refuses an entry that declares more than max, that expands past what it declares, or that does not fit in what is left of max_total_bytes. At most 64 KiB is reserved before bytes arrive, whatever the entry declares. Only a read that succeeds is charged.
  • has_duplicate_names: zip keeps one entry per name, so a central directory with more headers than zip has entries repeats a name.
  • read_head(name, n) reads the first n bytes only (a signature), charged to the total.
  • compressed_size with declared_size gives the compression ratio an entry claims, for a format that limits it.
  • A clone shares the directory and the bytes and keeps its own count, for reading behind &self.
  • budget_mut is for a caller with its own charging rule (charging a failed read, say).
  • Error::is_limit tells a limit from a broken archive.

Writing

Writer::add(name, data, Compression::Stored | Deflated | Level(n)), then finish(). Entries keep their order (a stored mimetype goes first by being added first). Every entry has the same 1980 timestamp, so equal input gives equal bytes. An entry of 2 GiB or more is written in the ZIP64 form. A repeated name is an error.

Strict preflight

preflight::preflight(bytes, limits) checks the whole structure on the raw bytes without allocating: exactly one end record may span the end of the file, the ZIP64 pair must agree with it, the directory must sit exactly before the end records and be filled exactly by its entries, single disk only, with limits on entries, directory bytes and name length. It is stricter than zip (it refuses archives with data before them and archives with two plausible end records), so Archive::open does not run it: a caller that wants nothing ambiguous to reach a ZIP reader calls it first.

preflight::read_entry(reader, declared, max, &mut budget) is the bounded read on any std::io::Read; Budget is a named limit with a running total.

Where it came from

preflight, read_entry and Budget: the ZIP half of the craft-archive proposal (#126, #129). check_entry_count: craft-opc's guard. Duplicate detection: craft-penpot's rule, made exact by walking the directory. The writer: craft-opc's ZipOut.