* 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
craft-opc
Open Packaging Conventions (ECMA-376 Part 2): the ZIP container that DOCX, XLSX and PPTX files
share. It replaces the package layer each Craft app carried for its own format (WordCraft's
docx::package, GridCraft's xlsx::package, DeckCraft's pptx::opc).
What the parts hold stays with each format's crate. Parts are read with zip and XML with
craft-xml.
use craft_opc::{Limits, Package, PackageOut, RelsOut};
let mut out = PackageOut::default();
out.xml_part("xl/workbook.xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", b"<workbook/>".to_vec());
out.binary("xl/media/image1.png", "image/png", vec![0x89, b'P', b'N', b'G']);
let mut rels = RelsOut::default();
rels.add("officeDocument", "xl/workbook.xml", false);
out.rels("", &rels);
let bytes = out.finish().unwrap();
let limits = Limits::new(10_000, 64 << 20, 256 << 20);
let pkg = Package::open(&bytes, &limits).unwrap();
let main = pkg.rels("").first("officeDocument").map(|r| r.target.clone()).unwrap();
assert_eq!(main, "xl/workbook.xml");
assert_eq!(pkg.content_types().of("xl/media/image1.png"), Some("image/png"));
assert!(pkg.get("XL/Workbook.xml").is_some());
Reading
Two readers, because the apps read in two ways:
Package |
LazyPackage |
|
|---|---|---|
| Inflates | every part, in open |
a part when read asks for it |
| Borrows the file's bytes | no | yes |
| A part over a limit | refuses the package, or is left out (Limits::oversized) |
is that read's error |
| A damaged entry | left out with a logged warning | is that read's error, and ends the budget (read_optional_limited: costs only its own allowance) |
| Came from | WordCraft (refuse), DeckCraft (leave out) | GridCraft |
Both look parts up without regard to case, leading / or \ for /.
Limits
Limits has no defaults: each format keeps its own numbers.
| Limit | Checked | Error |
|---|---|---|
max_entries |
from the end records, before zip indexes the entries (check_entry_count), and again on the count zip found |
TooManyEntries, TooManyEndRecords |
max_part |
on the declared size, then on what is inflated (a read stops one byte past it) | PartTooLarge |
max_total |
on the bytes kept | PackageTooLarge |
max_work |
on the bytes inflated, kept or not: an entry inflates in full before its checksum is compared, so entries that fail it would otherwise be free | PackageTooLarge |
Limits::new sets max_work to max_total and refuses oversized parts. At most 16 MiB is
reserved for a part before its bytes arrive, whatever size it declares.
check_entry_count is a bytes-only check of what the end of central directory records declare.
It does not validate the archive; zip does.
Names and relationships
Package::canonical(name)is the stored spelling. When entries differ only in case, they are one part: the spelling a relationship names is kept, else the first in byte order.Rels:by_id,target,first(type),all(type). A type is matched by its full URI or by its last segment, sofirst("image")finds the transitional and the strict spelling (type_is). The first relationship of a repeated id wins.Rel::targetis a part name for an internal target and the URL for an external one;target_rawis what the file wrote. A target is looked up as written, then with%hhescapes decoded; when the package has the part,targetis its stored spelling.resolve(dir, target)drops a fragment and cannot climb out of the package.relative(from, to)is its inverse.ContentTypes::of(part): Override by part name, then Default by extension.
A missing or unreadable relationships or content types part reads as empty, with a logged warning for an unreadable one.
Writing
PackageOut:xml_part(with an Override),binary(a Default for its extension, or an Override when the extension already has another type),raw,rels;finishwrites[Content_Types].xmlfirst and leaves out a second part under a name already written.RelsOut:addreuses the relationship already made for a type, target and mode;add_newnever does. Ids arerId1,rId2…ZipOutandzip_entriesfor a writer that builds its own content types. Output is deterministic: deflate level 6, one fixed timestamp. Names with an already-compressed extension are stored (compression_for).
Not here
- Flat OPC (
pkg:packageXML) and encrypted packages (a compound file holdingEncryptedPackage): still with the apps. - A full ZIP preflight (overlapping entries, lying offsets):
check_entry_countcovers the entry count only.