Files
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-penpot

A clean-room reader and writer for the Penpot 2.x export container: the .penpot archive (penpot/export-files, version 1). Offline: no network, no converter, no unsafe.

This crate is the container layer. It validates the ZIP, checks the manifest, and hands out the members as the JSON the file holds (serde_json::Value) and media objects as bytes; it writes members back into an archive. What the shapes, components and tokens mean, and how they map onto an application's document, stays with the caller.

Provenance

Written by FigCraft's contributors (the offline Penpot import and export, first by Guillermo Bueno) under FigCraft's clean-room rule: behaviour comes from public documentation and from observing files, no code of another product is copied, and no GPL or AGPL code is used. FigCraft's own notes say its tests do not establish compatibility with a live Penpot server; that holds here too. Moved here from figcraft/crates/format/src/penpot.rs (the archive half) and penpot/embedded_fonts.rs.

The format

A ZIP of JSON members and blobs:

Member Holds
manifest.json type (penpot/export-files), version (1), files: each with its id (a UUID) and name
files/<file>.json the file's header: features, version, migrations, options
files/<file>/pages/<page>.json a page: id, name, index, background, guides, flows
files/<file>/pages/<page>/<shape>.json one shape; the root frame has the nil UUID (ROOT_ID) and lists its children in shapes
files/<file>/components/<id>.json, colors/, typographies/ library entries
files/<file>/tokens.json design tokens
files/<file>/media/<id>.json a media record: mediaId names the stored object, mtype its type
objects/<id>.json, objects/<id>.<ext> a stored object's record and its bytes

Penpot 1.x binary exports are a different format and are not read.

Reading

let mut archive = craft_penpot::Archive::open(&bytes)?;
for file in archive.files.clone() {
    let header = archive.json(&format!("files/{}.json", file.id)).ok();
    for page in archive.pages(&file.id)? {                    // sorted by index
        let Some(page_id) = page.get("id").and_then(|v| v.as_str()) else { continue };
        let shapes = archive.page_shapes(&file.id, page_id)?; // id -> shape JSON
        let root = shapes.get(craft_penpot::ROOT_ID);
    }
    let colors = archive.uuid_members(&format!("files/{}/colors", file.id), 2048);
    let image = archive.media(&file.id, media_id, max_bytes);  // Option<Vec<u8>>
}
let fonts = archive.embedded_fonts();                          // counted by signature
archive.check()?;                                              // no budget was overrun

Members a caller treats as optional return errors it may drop; check then says whether a budget ran out along the way, so exhaustion is never swallowed.

Writing

let manifest = craft_penpot::manifest("my-app/1.0", &[(file_id, "Name", features)]);
let bytes = craft_penpot::write_archive([("manifest.json", manifest_bytes), (path, member_bytes)])?;

write_archive refuses paths Archive::open would refuse.

Limits

Input is hostile. Nothing panics; every limit has a test.

Limit Value
The archive (MAX_ARCHIVE) 512 MiB
ZIP members (MAX_ENTRIES) 100 000
Bytes unpacked, by declared sizes at open and by bytes actually read after (MAX_DECOMPRESSED) 1 GiB
A JSON member (MAX_JSON) 16 MiB
JSON values and keys, every member together, counted before parsing (MAX_JSON_VALUES) 4 000 000
Shape JSON for one page (MAX_PAGE_SHAPES_BYTES) 128 MiB
Shapes on one page (MAX_PAGE_SHAPES) 50 000
Pages in one file (MAX_PAGES) 500
Files in the manifest (MAX_FILES) 50
A media object (MAX_IMAGE; media also takes the caller's own cap) 128 MiB
JSON nesting serde_json's 128 levels

Duplicate member names (told by the directory's own member count, since the ZIP reader keeps one member per name), absolute paths, . and .. parts, backslashes and control characters fail the archive. Ids are checked to be UUIDs before they are used in a member path.

ZIP

The archive is read with the zip crate directly, with the bounds above applied to each member. A shared bounded ZIP reader is planned (craft-libs #126); this crate moves to it when it lands.

Not here yet

A typed model of the shapes (frames, groups, paths, text content, fills and strokes, layout, components and variants, tokens, interactions). FigCraft maps the JSON straight onto its own document today; a model mirroring the file's JSON is the next step.