Files
craft-libs/crates/opc
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
..

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, so first("image") finds the transitional and the strict spelling (type_is). The first relationship of a repeated id wins.
  • Rel::target is a part name for an internal target and the URL for an external one; target_raw is what the file wrote. A target is looked up as written, then with %hh escapes decoded; when the package has the part, target is 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; finish writes [Content_Types].xml first and leaves out a second part under a name already written.
  • RelsOut: add reuses the relationship already made for a type, target and mode; add_new never does. Ids are rId1, rId2…
  • ZipOut and zip_entries for 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:package XML) and encrypted packages (a compound file holding EncryptedPackage): still with the apps.
  • A full ZIP preflight (overlapping entries, lying offsets): check_entry_count covers the entry count only.