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

OpenDocument packages (ODF 1.3 Part 2): what every OpenDocument file has in common, whatever it holds. Text documents, spreadsheets, presentations and drawings all sit in the same container; this crate reads and writes that container and nothing inside it.

Module What it does
Package, PackageWriter The ZIP container: the mimetype file, the manifest, the files, within Limits.
Manifest META-INF/manifest.xml: each file's media type, and how an encrypted file is encrypted. Read by namespace, whatever prefix the file uses; parse_strict / Package::manifest_strict refuse a damaged one instead of reading what they can.
crypt Password-protected packages: AES-256-CBC, SHA-256 start key, PBKDF2 (HMAC-SHA-1), SHA-256 or SHA-1 checksums. A package in, a package out.
flat Flat single-file documents (.fodt, .fods, .fodp, .fodg): one office:document taken apart into a package's files, and a package put together into one.
zip_guard Refuses a ZIP whose end record declares too many entries, before zip reserves room for them.
use craft_odf::{Limits, Package, PackageWriter, crypt, flat, mime};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut w = PackageWriter::new(mime::SPREADSHEET);
    w.file("content.xml", "text/xml", b"<office:document-content/>");
    w.stored("Pictures/chart.png", "image/png", b"...");
    let plain = w.finish()?;

    let locked = crypt::encrypt(&plain, "password")?;
    assert!(crypt::is_encrypted(&locked));
    let opened = crypt::decrypt(&locked, "password")?;

    let mut p = Package::open(&opened, &Limits::DEFAULT)?;
    assert_eq!(p.mimetype().as_deref(), Some(mime::SPREADSHEET));
    assert_eq!(p.read("content.xml")?.as_deref(), Some(b"<office:document-content/>".as_slice()));

    let one_file = flat::from_package(&opened, &Limits::DEFAULT)?;
    let again = flat::to_package(&one_file, mime::SPREADSHEET, &Limits::DEFAULT)?;
    assert_eq!(Package::open(&again, &Limits::DEFAULT)?.mimetype().as_deref(), Some(mime::SPREADSHEET));
    Ok(())
}

Limits

Input is hostile. Nothing here panics on any bytes.

Limit Default Where
Entries a ZIP may declare 20,000 Limits::max_entries, checked on the raw bytes before zip parses the directory
Bytes one file inflates to 256 MiB Limits::max_entry; the size a file declares is not trusted
Bytes read from one package 2 GiB Limits::max_total
mimetype file 255 bytes longer is no media type
Manifest entries 20,000 the rest are dropped
PBKDF2 iterations for one file 10,000,000 more is refused
Key-derivation work for a package 200,000,000 iteration-blocks a thousand files as LibreOffice writes them
Files decrypted 10,000
Pictures and objects taken out of a flat document 10,000 the rest are dropped
Nesting of one element name in a flat document 512 deeper is treated as unclosed

Not supported

  • Blowfish CFB (the default before ODF 1.2) and OpenPGP encryption: such packages are refused with a message that names the algorithm.
  • Digital signatures (META-INF/documentsignatures.xml) are not checked or written; the file is kept like any other.
  • The meaning of content.xml, styles.xml, meta.xml and settings.xml.

Provenance

Moved from WordCraft (crates/formats/src/odt/crypt.rs, odt/flat.rs, the package reading and writing in odt/import.rs and odt/package.rs, and crates/doc/src/zip_guard.rs), with GridCraft's namespace-aware manifest reading. Written from the OASIS OpenDocument 1.3 specification, W3C XML Encryption (padding), RFC 2898 (PBKDF2) and PKWARE's APPNOTE.TXT.