# Parser inventory

Every engine module that turns attacker-controlled bytes or text into structure, with the bounds it actually declares, the test that covers it and whether the fuzz harness reaches it.

"Attacker-controlled" here means one of four sources:

- a file the user picked (upload, share target, drag-and-drop, `file` input),
- an asset fetched over `host.net` or read back out of storage,
- a URL param, because URL mode is first-class and every link is public,
- a stream nested inside one of those, for example an ICC profile inside a JPEG, a CMap inside a PDF content stream or an XML part inside a .pptx zip.

Two things this page deliberately does *not* do. It does not list the engine's format **writers** as attack surface. `engine/src/tiff.ts`, `apng.ts`, `webp-anim.ts`, `emf.ts`, `eps.ts`, `dxf.ts`, `hdr.ts`, `pptx.ts`, `pdfx.ts`, `zip-crypto.ts` and `pdf-crypto-r6.ts` are byte emitters that consume engine-internal IR, pixel buffers or already-encoded frames from the shell, so a hostile *file* never reaches them (see "Writers, not parsers" below). And it does not restate the shell's own gates: the web shell caps upload size and inflates zips before the engine sees a part map, and those caps live in `shells/web/src/bridge/`, not here.

Fuzz coverage is the registered target list in `tests/fuzz/targets.ts` (`ALL_TARGETS`, at the end of the file). There are 70: `layout-abi`, `penpot-layout`, `design-guides`, `design-hierarchy`, `lolly-hierarchy`, `adobe-xml`, `adobe-psd`, `idml-read`, `motion-cues`, `motion-params`, `mcp-negotiation`, `text-source`, `text-document`, `text-frame`, `vector-paint`, `deep-image`, `emoji-bundle`, `jxl`, `lottie-edits`, `lottie`, `emoji-pack`, `emoji-svg`, `emoji-text`, `prepare`, `c2pa-verify`, `cbor`, `media-sniff`, `output-facts`, `pdf-map`, `pdf-derived`, `x509`, `file-metadata`, `strip-metadata`, `video-meta`, `data-import`, `brand-import`, `brand-reference`, `tar-read`, `epub-read`, `jpeg-structure`, `raster-decode`, `pptx-read`, `pptx-patch`, `pptx-bridge`, `icc`, `der-read`, `c2pa-extract`, `c2pa-containers`, `url-pack`, `wav`, `depth-hint`, `lut-parse`, `psd`, `psd-descriptor`, `xcf`, `docx-read`, `svg-readers`, `keyframes`, `present-interact`, `midi`, `zzfxm`, `rondo-source`, `rondo-staged`, `radiance`, `seal`, `png-unfilter`, `watermark-analysis`, `geom`, `svg-items` and `font-inspect`. `tests/fuzz-regression.test.ts` replays the eight saved regression inputs in `tests/fuzz/regressions/` and runs a few hundred seeded mutations per target inside the normal `pnpm test` glob; `node tests/fuzz/run.ts` is the standalone soak.

The table is enforced by `pnpm run check:parser-assurance`. Every parser row must match a real `ALL_TARGETS` entry or an owned, expiring waiver in `security/parser-assurance.json`; stale rows, stale register entries, target drift and expired waivers fail CI. The weekly `fuzz-soak.yml` workflow exercises all registered targets and retains the failing input.

## The parsers

<!-- The parser table and fuzz-target census are generated by scripts/build-parser-inventory.ts. Edit security/parser-assurance.json. -->

| Module | Input source | Bounds enforced | Direct test file | Fuzz target | Notes |
|---|---|---|---|---|---|
| `engine/src/emoji-svg.ts` | Source SVG bytes from a verified emoji pack, with injected XML parsing after digest admission | 2 MiB source, only the exact standard SVG 1.1 doctype, no entity declarations or processing instructions, 4,096 elements, depth 32, 48 attributes per element, 1 M attribute characters; strict element/attribute/paint subset with local gradient, clipPath and local shape-use references only | `tests/emoji-svg.test.ts`, `tests/emoji-line.test.ts`, `tests/emoji-line.browser.test.ts` | `emoji-svg` recomputes pack/artwork digests before mutating static SVG, local gradients and clip paths | Unsupported visual features fail rather than being removed; inert declarations (enable-background, class, color, overflow, display other than none) are omitted and recorded, and a display:none element is dropped with its paint servers kept referenceable. Canonical source pixel equality is measured for four fixtures in the suite and for every admitted glyph of four full families by `scripts/import-emoji-pack.ts --verify` (a local run, not CI). No text, images, masks, filters, animation or cyclic/group use targets. |
| `engine/src/emoji-svg-syntax.ts` | SVG path data, transforms and numeric attributes after byte verification | 400 K path/list characters, 100 K arguments, 10 K segments, 64 transforms per attribute, finite scalar magnitudes at most 1 M | `tests/emoji-svg.test.ts` | `emoji-svg` reaches strict numeric and path grammar checks | No tolerant prefix recovery or arc approximation. Packed arc flags are unsupported in the initial subset. |
| `engine/src/emoji-segment.ts` | Mixed text and emoji strings from authored line specimens | 65,536 UTF-16 code units, rejects unpaired surrogates; binary lookup in pinned Unicode 17 tables and a linear scan | `tests/emoji-segment.test.ts`, `tests/emoji-line.test.ts` | `emoji-text` mutates UTF-8 cluster, selector, Indic and regional-indicator seeds | Passes the complete Unicode 17 GraphemeBreakTest corpus. Unknown emoji clusters stay whole. Bidi property lookup guards an LTR specimen and does not implement paragraph bidi layout. |
| `engine/src/emoji-pack.ts` | Pinned UTF-8 emoji pack manifests and separate source SVG byte snapshots | 32 MiB manifest, 20,000 glyphs, 64 notices of at most 256 KiB each, bounded strings and metrics; 2 MiB artwork; fixed-depth JSON schema and unique semantic identities | `tests/emoji-pack.test.ts`, `tests/emoji-resolve.test.ts`, `tests/emoji-browser.test.ts` | `emoji-pack` mutates a real pinned manifest and recomputes the transport digest to reach validation | Checksums and schema admission do not sanitize SVG or prove source rights. The resolver accepts opaque admitted handles and cannot substitute another installed version. Unicode sequence lookup uses vendored data, with a separate 32-scalar input limit. |
| `engine/src/pdf-map.ts` | PDF or Illustrator `.ai` content stream, decoded by the shell's pdf-lib pass; plus each font's `/ToUnicode` CMap text | `PDF_MAP_MAX_ARRAY_DEPTH = 16`, `PDF_MAP_MAX_BF_RANGE = 0x10000`, `PDF_MAP_MAX_RUN_DEPTH = 12`, `PDF_MAP_MAX_PAGE_NODES = 4,000`, `PDF_MAP_MAX_TOKENS = 4 M`, `PDF_MAP_MAX_CONTENT_CHARS = 16 M`, `PDF_MAP_MAX_TOTAL_CONTENT_CHARS = 32 M`, plus named pattern/mask evaluation and node ceilings | `tests/pdf-map.test.ts` | `pdf-map` (`interpretPdfPage` + `parseToUnicode`) | Two of the eight saved regressions are this module: `pdf-map-deep-array-nesting.bin` and `pdf-map-tounicode-range-oom.bin`. The CMap cap blocks a four-billion-iteration range; tokenizer accounting includes nested array members, and decoded characters, tokens and emitted nodes are charged page-wide across nested form, glyph, pattern and mask runs. |
| `engine/src/pdf-svg.ts` | the `PdfNode`s `pdf-map.ts` produced for one page | 4,000 nodes; 64 clips/mask children; 4,096 gradient stops; 20,000 text/outline lines; 32 M aggregate source chars; 64 K clip, 400 K path/outline and 1e9 coordinate limits | `tests/pdf-svg.test.ts` | `pdf-derived` (serializer, extent and culler over generated hostile nodes) | A preflight charges allocation-driving strings/collections before serialization. Oversized serializer input throws; the total culler fails open without walking an over-budget list. |
| `engine/src/pdf-text.ts` | the same interpreted text nodes, reassembled into reading order | 4,000 nodes; 20,000 line items/tagged elements; 16 M chars; 4,000 MCIDs per element and 100,000 aggregate tagged references; 10,000 joined pages | `tests/pdf-text.test.ts` | `pdf-derived` (`extractPageText` + `joinPageText`) | Newline splitting is a forward scan capped before allocation; structure-tree fan-out and public direct callers cannot bypass the interpreter-sized page budget. |
| `engine/src/pdf-artwork.ts` | interpreted PDF nodes, clustered into candidate logos | `PDF_ARTWORK_MAX_NODES = 4,000`, `PDF_ARTWORK_MAX_CANDIDATES = 60`, plus the shape heuristics `MAX_CLUSTER_GAP = 48`, `MAX_PAGE_FRACTION = 0.55`, `MAX_ASPECT = 12` | `tests/pdf-artwork.test.ts` | `pdf-derived` (`findVectorArtwork`) | The input-visit cap applies to all nodes, not only accepted vector items; a hostile all-text list cannot force an unbounded scan before clustering. |
| `engine/src/pdf-redaction.ts` | interpreted PDF nodes, looking for text under filled bars | 4,000 nodes/page, 64 covering shapes/run, 1 M chars/run and 10,000 pages/document | `tests/pdf-redaction.test.ts` | `pdf-derived` (page/document detection and summary) | Detection only, no byte surgery. Paint-order and cover scans share the interpreter-sized prefix budget. |
| `engine/src/pptx-read.ts` | an unzipped `.pptx` part map (`Record<path, Uint8Array \| string>`) plus an injected XML parser | `MAX_PART_BYTES = 24 MB`, `MAX_PART_CHARS = 16 M`, `MAX_SLIDES = 2000`, `MAX_NODES_PER_SLIDE = 8000`, `MAX_GROUP_DEPTH = 16`, `MAX_PARAS = 4000`, `MAX_RUNS_PER_PARA = 4000`, `MAX_TABLE_ROWS = 2000`, `MAX_TABLE_COLS = 512`, `MAX_TEXT_LEN = 200_000`, `MAX_DFS_VISITS = 200_000`, `MAX_COORD = 1e11` | `tests/pptx-read.test.ts` | `pptx-read` (`readPptx` + `isPptx`) | The exemplar. See "The standard to follow". Entity expansion is explicitly delegated to the injected parser, and the module compensates with a visited-node counter. Speaker notes come back twice: as joined text, and as paragraphs (`notesParas`) in which an `a:br` stays a line-break run, so `lolly read` and `lolly_read` can keep paragraphs and line breaks apart; both go through the same `readTxBody` bounds. |
| `engine/src/svg-items.ts` | SVG markup from a slide picture (`asvg:svgBlip`) plus an injected XML parser, and `a:custGeom` path lists already read by `pptx-read.ts` | `VECTOR_ITEM_MAX_NODES = 20_000`, `VECTOR_ITEM_MAX_ENCODED = 400_000` characters per Design path value, `MAX_DEPTH = 64`, `MAX_USE_DEPTH = 8` with a `<use>` cycle check, `VECTOR_ITEMS_MAX = 2000` items and `VECTOR_ITEMS_MAX_CHARS = 1_048_576` characters of path data per object, `VECTOR_DECK_ITEMS_MAX = 20_000` per deck, `MAX_VECTOR_ROWS_PER_OBJECT = 400`, `MAX_VECTOR_ROWS_PER_FRAME = 1200` | `tests/svg-items.test.ts` | `svg-items` (`svgItemsOf` then `vectorItemsToRows` and `vectorItemsSvg`) | DOM-free. Foreign namespaces are ignored, an invisible part is dropped, and an unsupported part is left out with its reason in `omitted`. Reaching a cap stops the read with `cap-reached` and the caller keeps the picture. |
| `engine/src/pptx-patch.ts` | the same hostile part map, rewritten in place for a rebrand | `MAX_PART_CHARS = 32 M` | `tests/pptx-patch.test.ts` | `pptx-patch` (`rebrandPptxParts`) | Every rewrite is a delimited string or regex edit with linear regexes only, never a DOM parse, so no catastrophic backtracking. A part over the cap passes through verbatim rather than being rewritten. |
| `engine/src/docx-read.ts` | an unzipped `.docx` part map plus an injected XML parser - the document twin of `pptx-read.ts` (the web shell's `lib/office-text.ts` inflates the zip first) | `MAX_PART_BYTES = 24 MB`, `MAX_PART_CHARS = 16 M`, `MAX_PARAGRAPHS = 20_000`, `MAX_BLOCKS = 20_000`, `MAX_RUNS_PER_PARA = 4_000`, `MAX_INLINES_PER_PARA = 8_000`, `MAX_TABLE_ROWS = 2_000`, `MAX_TABLE_COLS = 512`, `MAX_TABLE_CELLS = 100_000`, `MAX_TABLE_DEPTH = 8`, `MAX_INLINE_DEPTH = 16`, `MAX_LIST_LEVEL = 8` | `tests/docx-read.test.ts` | `docx-read` (`readDocx` plus both `doc-md` serialisers) | By contract it never throws on content - only a `vbaProject.bin` part is refused - so the fuzz sweep's only possible findings are a hang, an allocation blow-up or stack depth. |
| `engine/src/icc.ts` | an ICC profile lifted out of a user-supplied JPEG, PNG, PDF or TIFF | `MAX_TAGS = 512`, `MAX_CHANNELS = 15`, `MAX_TABLE_ENTRIES = 4096`, `MAX_CURVE_ENTRIES = 65536`, `MAX_CLUT_VALUES = 1 << 22`, `MAX_PARA_PARAMS = 7`, `MAX_CURVE_INVERT_STEPS = 40` | `tests/icc.test.ts`, `tests/icc-real-profiles.test.ts` | `icc` (`parseIccProfile` + evaluate) | The clearest statement of the house reader contract in the codebase: never throws, malformed input yields `null`, nothing the file declares is trusted and every cap comment gives the largest value seen across the forty-odd real profiles. Apple's over-reporting `gamt` tag is handled by name. |
| `engine/src/file-metadata.ts` | raw bytes of any uploaded raster, vector or MP4 or QuickTime video | `MAX_FIELDS = 64`, `MAX_VALUE_CHARS = 2048`, `MAX_TEXT_SCAN = 16 MB`, `MAX_GIF_BLOCKS = 1_000_000`, `MAX_MPF_IMAGES = 16`, `MAX_GAINMAP_SNIFF = 8192` | `tests/file-metadata.test.ts` | `file-metadata` (`extractFileMetadata`) | Best-effort throughout: a malformed block yields fewer fields, never an exception. PDF is deliberately out of scope here and goes through `host.pdf.analyze`. Second parse path, added 2026-07-31: `readMpfIndex` reads a JPEG's CIPA DC-007 multi-picture index (both byte orders) over the `jpeg-segments.ts` walker, so a declared second image - an HDR gain map, an MPO - is NAMED in the reveal instead of being sniffed as an unexplained appended payload and flagged `sensitive`. Nothing the index claims is trusted: an image is reported only once its declared range lies inside the buffer and starts on an SOI. |
| `engine/src/strip-metadata.ts` | the same uploaded bytes, for the clean-copy path | no numeric caps; the invariants are structural (`STRIPPABLE`, `PNG_STRIP`, `DROP_EL_PREFIX`, `DROP_EL_NAME`, `SPACE_SENSITIVE`, `DROP_XMLNS`) | `tests/strip-metadata.test.ts` | `strip-metadata` (`stripMetadata`) | Deliberately the inverse contract to its read-side sibling. Consumes `file-metadata.ts`'s `readMpfIndex` for one documented exception to "image content is preserved": a multi-picture JPEG loses every image after the primary along with the MPF index that declared them, because an orphaned second image behind a deleted index is worse than either alternative (module header states the choice). Files with no MPF index - motion photos included - are byte-for-byte unaffected. This is a privacy control, so it must **fail closed**: if the surgery throws or leaves removable metadata behind, `stripMetadata()` throws rather than return an original a caller would present as clean (`hasResidualMetadata`). |
| `engine/src/jpeg-segments.ts` | the marker structure of any JPEG - one a user uploaded, and one our own export pipeline is about to stamp | `JPEG_MAX_SEGMENTS = 4096`, `JPEG_MAX_APP_ID = 64`; every length field is checked against the buffer before it is acted on, and entropy data is walked (`FF 00` stuffing, `FF D0`–`FF D7` restarts) rather than searched | `tests/jpeg-segments.test.ts` | `jpeg-structure` (scan/find/body/splice over minimal, MPF and mutated files) | The one shared JPEG marker walker/writer, replacing three ad-hoc "skip the leading APP0" insertion points now that segment ORDER carries meaning (an MPF index stores absolute offsets, so it must precede ICC). The contract is split by side: the reader follows the house rule - never throws, `null` when the bytes are not a JPEG, a short scan flagged `truncated` when they are malformed - while `insertJpegSegments` follows the splicer convention it replaces and returns the input bytes untouched, all or nothing, on any problem. The structural invariant "no reported segment ends past the buffer" is asserted on every scan in the tests, including a few hundred seeded mutations of a real `sharp`-encoded file. |
| `engine/src/radiance.ts` | the bytes of an uploaded or piped Radiance `.hdr` (RGBE) file | `RADIANCE_MAX_HEADER_BYTES = 64 KB`, `RADIANCE_MAX_PIXELS = 32 Mpx` and `RADIANCE_RLE_MAX_WIDTH = 32767`; every scanline's declared run/literal length is checked against the remaining buffer before it is acted on | `tests/radiance.test.ts` | `radiance` (header plus reader, seeded from both writer encodings) | Reader half of the writer (`readRadiance`). Follows the house reader rule: never throws, `null` on anything it cannot fully verify. Two decode shapes it deliberately REFUSES rather than mis-render: `-X` (mirrored rows) and `+Y` (bottom-up), because rows are returned in file order and accepting either would silently hand back a flipped picture. |
| `engine/src/gainmap-jpeg.ts` | the MP Index IFD of any JPEG being C2PA-stamped - ours, or one a user uploaded | every IFD/entry offset is bounded to the **MPF segment** before any read or write (not merely to the file), the entry count must match the images actually present and both byte orders are read | `tests/gainmap-jpeg.test.ts`, `tests/c2pa-gainmap.test.ts` | `jpeg-structure` (`repairMpfOffsets` and `assembleGainMapJpeg` over valid MPF plus forged mutations) | Write side as well as read: `repairMpfOffsets` runs from `c2pa-containers.ts#placeJpeg`, because inserting an APP11 store into the primary grows it and leaves `MPEntry[0].size` under-reporting - a malformed index per DC-007 on a file we then sign. Bounding the writes to the segment is what keeps a forged offset from steering 16 bytes per image into arbitrary image data (regression pinned in `tests/c2pa-gainmap.test.ts`). Returns the input untouched on anything it cannot fully verify. |
| `engine/src/media-sniff.ts` | header prefix of any uploaded raster or video | none named; every read is an explicit `offset + len > bytes.length` check and the GIF/PNG walks bail at fixed counts | `tests/media-sniff.test.ts` | `media-sniff` (`sniffAnimatedRaster` + `sniffVideoContainer`) | Returns `string \| null`, allocates nothing beyond a short scan. The "GIF lesson" the DER and CBOR walkers cite as their invariant originated here. `sniffLayeredRaster` (2026-08-04) is prefix-only PSD/XCF classification for the drop router; full validation is the two rows below. |
| `engine/src/output-facts.ts` | A finished export read back for the export report: raster, video, SVG, EPS, PDF and ZIP bytes produced by a shell or handed to `lolly run --export-report` | 512 MiB per file before any reader runs; 64 MiB for text scans (PDF, SVG, EPS); 32 MiB inflated in total and 8 MiB per PDF stream, inflated in 4 KiB input pieces so the work stops near the cap; 200,000 chunks, blocks or entries per container; 10,000 ISO boxes per level; PDF page-tree depth 64 and 10,000 leaves; 64 KiB per PDF object dictionary; 100,000 objects per object stream, whose offsets must rise; 256 KiB for the SVG root tag. Time is linear in the bytes read: PDF dictionaries end at the next object header so none overlap, the endobj and endstream searches only move forward, number runs are matched with bounded counts and SVG tags without a variable-length tail, and `tests/output-facts.test.ts` reads about 2 MB of each input that once made a reader quadratic within a fixed time budget | `tests/output-facts.test.ts`, `tests/preflight-output.test.ts` | `output-facts` mutates PNG, JPEG, GIF, WebP, TIFF, MP4, WebM, SVG, EPS and PDF seeds, including a page tree inside a compressed object stream, an SVG print sheet and short copies of the inputs that once made a reader quadratic | Total by construction: every reader runs under a guard and an unread value stays an unknown `Fact` with a recorded limit. Container detection is `engine/src/format-sniff.ts`, shared with the Node output guard. Credential presence uses `extractC2paStore` and is never reported as verification. |
| `engine/src/psd.ts` | a user's whole Photoshop PSD/PSB, on the layered-import path | `MAX_DIM_PSD = 30_000`, `MAX_DIM_PSB = 300_000`, `MAX_LAYERS = 1_024`, `MAX_CHANNELS_PER_LAYER = 8`, `MAX_EXTRA_BLOCKS = 256`, `MAX_RESOURCE_BLOCKS = 1_024`, `MAX_CMYK_CACHE = 1 << 20` and a decode-output budget (`maxDecodedBytes`, default 256 MiB) reserved **before** every allocation | `tests/psd.test.ts` | `psd` (`readPsd`, seeds from `writePsd` - our own writer) | Split failure contract: not-a-PSD / refused class (Lab/Indexed, 1/32-bit) is a typed `PsdUnsupportedError` (a controlled throw); damage inside a layer/resource is `onWarn` + skip that piece, never the document. ZIP channels inflate through an injected `InflateFn` whose `maxOut` the module re-checks. CMYK converts through the embedded ICC profile via `icc.ts`'s bounded evaluator. Adjustment layers: `psd-adjustments.ts` reads eight kinds from the bounded block copies (a short block or an unknown version reads as nothing), and an adjustment layer's raster mask is decoded through the same budget only to learn whether it covers the canvas; both are reached by the `psd` and `psd-descriptor` targets. |
| `engine/src/xcf.ts` | a user's whole GIMP XCF, same path | `MAX_DIM = 300_000`, `MAX_LAYERS = 1_024`, `MAX_PROPS = 512`, `MAX_NAME = 4_096`, the same `maxDecodedBytes` budget; every pointer validated `14 < p < bytes.length` before deref, tile byte length bounded by the gap to the next tile pointer (worst-case-capped for the last) | `tests/xcf.test.ts` | `xcf` (`readXcf`, seeds from `tests/helpers/xcf-fixture.ts`'s builder) | Same split contract (`XcfUnsupportedError` vs warn+degrade: a bad tile becomes a transparent tile, a bad layer a geometry-only row). GIMP's tile RLE is its own scheme (not PackBits) with per-opcode bounds; zlib tiles go through the injected `InflateFn`. Linear/float precisions are refused by class rather than mis-folded. There is deliberately no XCF writer (see psd-write.ts's header). |
| `engine/src/bmp.ts` | a dropped or pasted Windows `.bmp` | `BMP_MAX_DIM = 0x7fff`, `BMP_MAX_PIXELS = 64 Mpx`; the 54-byte header, pixel offset and `pixelOffset + stride * height` are range-validated before allocation/deref | `tests/bmp.test.ts` | `raster-decode` | Decoder half of an encoder/decoder pair. A typed `BmpUnsupportedError` says which class it refused rather than mis-decoding compressed, paletted or 1/16-bit data. The pixel cap prevents the individually legal width/height maxima multiplying into a multi-gigabyte RGBA allocation. |
| `engine/src/ico-decode.ts` | a dropped Windows `.ico`/`.cur`, on the icon-import path | `ICO_MAX_ENTRIES = 4,096`, `ICO_MAX_DIM = 8,192`, `ICO_MAX_PIXELS = 32 Mpx`, `ICO_MAX_INPUT_BYTES = 256 MB`; every directory count/size/offset is range-validated | `tests/ico-decode.test.ts` | `raster-decode` | Picks the largest entry. Headerless DIB output is allocation-bounded; a PNG payload is returned for native decoding only after its IHDR geometry passes the same host hand-off ceiling, so a tiny ICO cannot ask the browser to allocate an enormous canvas. |
| `engine/src/apng-decode.ts` | an animated PNG a user dropped, split to per-frame stills | `APNG_DEMUX_MAX_INPUT_BYTES = 256 MB`, `MAX_CHUNKS = 100,000`, `MAX_FRAMES = 4,096`, `MAX_DIM = 32,768`, `MAX_PIXELS = 128 Mpx`, `MAX_OUTPUT_BYTES = 512 MB`; every chunk/region is range-validated | `tests/apng-decode.test.ts` | `raster-decode` | Chunk-level surgery only. Frame count, geometry and aggregate standalone-PNG amplification are refused before materialisation; every frame must fit the declared canvas before the host receives it. |
| `engine/src/webp-anim-decode.ts` | an animated WebP a user dropped, same path | `WEBP_DEMUX_MAX_INPUT_BYTES = 256 MB`, `MAX_CHUNKS = 100,000`, `MAX_FRAMES = 4,096`, `MAX_DIM = 32,768`, `MAX_PIXELS = 128 Mpx`, `MAX_OUTPUT_BYTES = 512 MB`; RIFF and `ANMF` boundaries are enforced | `tests/webp-anim-decode.test.ts` | `raster-decode` | Same demux contract as APNG. Parsing stops at the RIFF-declared end; short control chunks, excessive host-decode geometry, frame regions outside the canvas and aggregate still-output amplification are refused. |
| `shells/web/src/lib/image-sample.ts (depthHint)` | header prefix (plus, for TIFF, two tiny targeted slices) of any uploaded raster, on the ingest path | `MAX_SNIFF_READ = 64 KB` per read, `MAX_IFD_ENTRIES = 512`, `MAX_JPEG_SEGMENTS = 512`; every read is an explicit bounds-checked slice | `shells/web/src/lib/image-sample.test.ts` | `depth-hint` (`depthHint`) | Shell-side sibling of `media-sniff.ts` (listed here because its input is the same untrusted upload bytes): reports a file's declared bits per channel - PNG IHDR byte 24, TIFF tag 258 via a bounded first-IFD walk, JPEG SOFn precision - without decoding a pixel, so the ingest path can say "16-bit source, edited at 8-bit" instead of crushing silently. Never throws; malformed input answers nulls. |
| `community/darkroom/hooks.js (parseCube, parse3dl)` | a `.cube` or `.3dl` colour LUT the user picked in Darkroom | `CUBE_MAX_N = 129`, `TDL_MAX_N = 65` (grid size), declared in the hooks beside the parsers | none of its own; the fuzz target is the coverage | `lut-parse` (both parsers, over the real hook source) | The one **tool-data** parser that reads untrusted bytes, listed here because the input source is the same as every row above. Hooks ship as plain script rather than a module, so the target lifts the two functions out with `new Function` exactly the way the engine runtime compiles them. A controlled "not a … LUT" / "too large" throw is the desired outcome; hangs and allocation blow-ups are the findings. |
| `engine/src/video-meta.ts` | a finished MP4 or WebM from MediaRecorder, or a user's video on the ingredient path | none named | `tests/video-meta.test.ts` | `video-meta` (`embedMp4Meta` + `embedWebmMeta`) | Mixed writer and walker. It writes tags, but to do so it must walk attacker-supplied container structure, and its `readId`/`readVint`/`walkBoxes`/`scanSegmentChildren` primitives are imported by both `c2pa-extract.ts` and `c2pa-containers.ts`, so this is the shared read path for BMFF and Matroska. `video-meta-stco-forged-count.bin` is a saved regression. Unrecognised structure returns the original bytes untouched. |
| `engine/src/c2pa-extract.ts` | the JUMBF manifest store inside any user file, and the CBOR claims inside it | `MAX_CBOR_DEPTH = 64` | `tests/c2pa-extract.test.ts`, plus coverage through `tests/c2pa-verify.test.ts`, `tests/c2pa-formats.test.ts`, `tests/c2pa-foreign-fixture.test.ts`, `tests/c2pa-jpeg-segments.test.ts` | `c2pa-extract` (`collectActionChain` + `prepareC2paIngredientFromStore` + `sniffFormat` + `parseC2paStore`), `cbor` (`decodeCbor`, hit directly) and `c2pa-verify` (end to end) | Split out of `c2pa-verify.ts` precisely so the parsing is reviewable apart from the cryptography; nothing in this file does or checks crypto. Four of the eight saved regressions are its CBOR decoder. The decoder deliberately accepts indefinite lengths and half/single/double floats because foreign manifests use them. The target deliberately runs the never-throw pair first, because `parseC2paStore`'s controlled throw would otherwise skip them. |
| `engine/src/c2pa-containers.ts` | the container a manifest is being spliced into - ordinarily Lolly's own render output, but on the ingest and re-attach paths a file a stranger sent | none named; the bounds are per-grammar and structural (each PNG chunk, JPEG segment, GIF block, RIFF/TIFF IFD, BMFF box, EBML element and PDF xref offset is validated against the buffer before it is acted on) | `tests/c2pa-containers.test.ts`, plus `tests/c2pa-formats.test.ts` and `tests/c2pa.test.ts` in aggregate | `c2pa-containers` (`sniffFormat` → `attachC2paStore`, or `embedC2paInPdf` for PDF) | Placement, not extraction - but placing means walking the host container's grammar, so it belongs here rather than under "Writers". Contract: it **throws** on a container it refuses to modify, so only a hang, a stack overflow or an allocation blow-up counts as a finding. `c2pa-containers-tiff-forged-ifd-pointer.bin` is a saved regression: a hostile TIFF IFD pointer. The target's format→placer map is a `Record<Exclude<SniffFormat, …>>` on purpose, so adding a placeable format without adding its placer row is a compile error rather than a silent forever-fuzzed-as-PNG. |
| `engine/src/c2pa-verify.ts` | a whole user file, plus the COSE signature and X.509 chain inside its manifest | `MAX_CHAIN_INTERMEDIATES = 8` | `tests/c2pa-verify.test.ts`, `tests/c2pa-trust.test.ts`, `tests/c2pa-verdict.test.ts`, `tests/c2pa-c2patool-conformance.test.ts` | `c2pa-verify` (`verifyC2pa`) | Reports failures as named checks rather than throwing, so a malformed manifest is a failed check and never an escaped exception. Chain walking consumes each intermediate at most once, so no A→B→A loop, and hostile chains are bounded to a trivial O(cap²). |
| `engine/src/der-read.ts` | DER/ASN.1 straight out of attacker-controlled files, shared by `c2pa-verify.ts`, `x509.ts` and `seal.ts` | none named; the invariant is that every multi-byte length head is bounds-checked *before* its bytes are read | `tests/der-read.test.ts` | `der-read` (`walkDer` + `ecdsaDerToRaw`), and indirectly through the `x509` and `c2pa-verify` targets | The opposite contract to the readers above, and correctly so: it **throws promptly** on truncation or an overrunning length. The module comment explains why silence would be worse, an out-of-range `Uint8Array` read yields `undefined`, which NaN-poisons the computed length and defeats the `j + len > b.length` guard. Not exported from `index.ts`. The target seeds from real certificates (`generateSigner`, `generateCaRoot`) plus real ECDSA-Sig-Values, and walks the whole tree before attempting conversion - the other order would throw on every certificate seed before the walker saw it. |
| `engine/src/x509.ts` | certificate DER from a manifest's `x5chain`, or from the CA service | none named beyond the DER walker's own checks | `tests/x509.test.ts` | `x509` (`parseCertificate`) | Both a writer (`generateSigner`, `generateCaRoot`, `issueLeafCert`) and a reader. The read side is the exposed half. |
| `engine/src/seal.ts` | raw bytes of any file, scanned for an embedded SEAL record | 64 KB edge/record windows, 64 records, 128 fields, 16 KB/field, 4 KB range grammar, 64 ranges and 256 MB assembled message, all public named constants | `tests/seal.test.ts` | `seal` (record parse and message assembly) | Verification only, and network-free: DNS key lookup is an injected `resolveKey`. Large files are no longer copied wholesale into a latin1 string: only bounded edge windows are materialised. Excess records/fields fail closed; a malformed or hostile file yields `[]`. |
| `engine/src/data-import.ts` | a user's CSV or JSON file, read to text by the shell | `MAX_IMPORT_CHARS = 8 M`, `DEFAULT_ROW_LIMIT = 1000` (both exported) | `tests/data-import.test.ts` | `data-import` (`parseDataRows`) | The `MAX_IMPORT_CHARS` comment states the reason the engine enforces it rather than trusting the shell: a missing shell gate must not let a gigabyte "CSV" of nothing but commas balloon into cell allocations. |
| `engine/src/brand-import.ts` | a Tokens Studio or DTCG token document, a per-set file tree or a `.penpot` project zip | `BRAND_IMPORT_MAX_PART_BYTES/CHARS = 16 MB/M chars`, `BRAND_IMPORT_MAX_JSON_UNITS = 64 M`, `BRAND_IMPORT_MAX_ENTRIES = 50,000`, `BRAND_IMPORT_MAX_PAGE_PARTS = 25,000`, `BRAND_IMPORT_MAX_TOKEN_DOCS = 512`, `BRAND_IMPORT_MAX_SET_FILES = 2,048`, `BRAND_IMPORT_MAX_TOKEN_SETS = 4,096`, `BRAND_IMPORT_MAX_NODES = 1,000,000`, `BRAND_IMPORT_MAX_DEPTH = 64` | `tests/brand-import.test.ts` | `brand-import` (all three token containers plus both Penpot page censuses) | Takes already-parsed JSON and already-unzipped path→bytes entries, so the engine applies structural and aggregate budgets independently of the caller's inflation guard. Extraction never throws; a limit violation fails the whole operation closed instead of returning a partial token document or census. Page discovery is one bounded collection plus sort, not manifest-files × archive-entries work. |
| `engine/src/tar-read.ts` | a `.tar` (or, gunzipped, `.tar.gz`) a user handed to an import path | USTAR field maximum `SIZE_MAX = 0o77777777777`; `TAR_MAX_MEMBERS = 10,000`, `TAR_MAX_PAYLOAD_BYTES = 256 MB`, `TAR_MAX_ARCHIVE_BYTES = 320 MB`; `gunzip` refuses trailer-declared output above its caller/default ceiling before inflation | `tests/tar-read.test.ts`, `tests/gzip-tar.test.ts` | `tar-read` (`readTar` + `readTarGz`, seeded from `packTar`) | A single forward scan with checksum and range validation. Member/payload/archive budgets apply before copying returned entries, including skipped directory/link/PAX payloads. Padding uses arithmetic rather than int32 bitwise rounding, so a large declared skipped member cannot move the cursor backwards. The web shell lowers the member cap to its own 200-file policy at the parser call, not after materialisation. |
| `engine/src/epub-read.ts` | an `.epub` of approved body copy, on the brand-boilerplate ingest path | shared `readZip` ceilings plus stricter public EPUB bounds: 64 MB input/aggregate output, 4,096 parts/spine items/nav labels, 16 MB per part, 10,000 manifest items and 4,096 chars per title; duplicate parts and manifest ids are refused | `tests/epub-read.test.ts`, `tests/ingest-epub.test.ts` | `epub-read` (real writer-produced OCF seeds plus malformed tag storms) | The engine stays DOM-free: one forward-only tag scanner extracts OPF/XHTML text without XML entity expansion or paired-tag regex backtracking. Structural absence and invalid OCF identity throw; malformed prose remains best-effort within explicit output budgets. |
| `engine/src/url-pack.ts` | the `z` and `zx` params of any shared link | `MAX_TOKEN = 64 KB`, `MAX_UNPACKED = 256 KB`; `PBKDF2_ITERATIONS = 210_000`, `ENC_SALT_BYTES = 16`, `ENC_IV_BYTES = 12` on the encrypted variant | `tests/url-pack.test.ts` | `url-pack` (`unpackToken` + `hasPackedState`, over both the token text and its decoded bytes) | The one module whose entire input is a URL param. Both caps exist because raw DEFLATE expands roughly 1000×, so a decompression bomb must not hang the tab. |
| `engine/src/keyframes.ts` | a box's `kf` field - free text, settable from a hand-edited share URL | `256` keyframes per track (the defining cap - parse work is bounded by it); a char cap **derived from the key cap** (`KF_MAX_CHARS`, 48 KB, not a flat 8 KB - a full-pose 256-key track serialises to about 15.8 KB, so a flat 8 KB would be unsatisfiable by the format's own round-trip law; the derivation is re-run by the test, and adding the `w`/`h` channels moved it from 40 KB to 48 KB); `t` clamped to `MAX_TIME_S * 1000`; each channel value clamped to its own stated range | `tests/keyframes.test.ts` | `keyframes` (parse, canonical serialise, reparse, evaluate and channel census) | Junk-tolerant by design: a token matching no channel or ease form is skipped, never thrown. Hooks consuming this module never emit the raw field - they parse and re-serialise, so only charset-clean tokens ever reach a rendered attribute, closing an attribute-injection path a hand-edited `kf` value would otherwise open. |
| `engine/src/present-interact.ts` | a Design web box's authored `interactOpts` field and standalone presentation depth values from documents or hand-edited share URLs | 1,024 UTF-8 bytes per options wire; 32 stops; depth text at most 256 UTF-16 code units and fragment names at most 128 characters; finite depth/page length clamped to 0-1,000,000, percentages to 0-100, seconds to 1-600, pause seconds to 0-600 and scroll milliseconds to 0-10,000 | `tests/present-interact.test.ts`, `tests/present-interact.browser.test.ts` | `present-interact` mutates standalone depths, valid controls, duplicate/prototype keys, malformed URI escapes and byte/stop limits through parse, canonical serialise/reparse, stop resolution and auto sampling | Options use a fixed key and value grammar with once-decoded URI components. Unknown or duplicate keys and malformed values reject the whole wire. Fragments stay opaque until the page runtime resolves them; the engine parser performs no DOM or network access. |
| `engine/src/svg-colors.ts` | raw SVG source text from an uploaded or fetched asset | `SVG_COLORS_MAX_CHARS = 4_000_000`, `SVG_COLORS_MAX_MATCHES = 100_000`, shared by both passes | `tests/svg-colors.test.ts` | `svg-readers` (attributes and CSS declarations) | String and regex work only, no DOMParser. Quoted attributes use a forward-only delimiter scan so malformed quote storms cannot repeatedly rescan the tail. Candidates pass the same `SAFE_CSS_COLOR` shape gate the web colour field uses, and a bare identifier must additionally be a real CSS3 named colour so a class name or font family cannot be misread as a colour. Never throws. |
| `engine/src/svg-layers.ts` | a user's uploaded SVG, on the "Lift layers" path (DOMPurify-sanitised by the shell first, but this module assumes nothing about that) | `SVG_LAYERS_MAX_CHARS = 4_000_000`, `SVG_LAYERS_MAX_TAGS = 40_000`, `SVG_LAYERS_MAX = 64` layers, `SVG_LAYERS_MAX_CANDIDATES = 4000` root children considered (spatial clustering is a pairwise union-find, so its cost is QUADRATIC - measured before the cap: 10 000 leaves 0.7 s, 20 000 leaves 4.3 s, 39 000 leaves 16 s), `SVG_LAYERS_MAX_DEPTH = 64`, `SVG_LAYERS_MAX_DESCENT = 8`, `SVG_LAYERS_MAX_REFS = 64`, `SVG_LAYERS_HEAVY_BYTES = 8_000_000` (a warning threshold on the DERIVED total, not a refusal - the whole `<defs>` rides into every layer, so one embedded raster multiplies by the layer count: an ordinary 1.0 MB file derived 24.0 MB) - all exported, so a consumer states the same numbers rather than guessing them | `tests/svg-layers.test.ts`, plus the rendered half in `tests/svg-lift-identity.browser.test.ts` | `svg-readers` (enumeration plus root viewBox scan) | Its own bounded tag scanner (no DOM, no XML library), and every emitted fragment is a VERBATIM SLICE of the input - nothing is re-serialised, so nothing can be corrupted on the way through. Never throws: junk yields fewer layers and more warnings, and the top-level try/catch is defence in depth rather than the contract. Work is linear in the input length with ONE deliberate exception, the quadratic clustering the candidate cap bounds - id resolution is a byte-span query against a single id index, not a regex per (layer x reference), which is what made a 3.7 MB document inside every cap take 10.7 s in 1.119.0. The layer cap MERGES THE TAIL instead of truncating, because a cap that dropped artwork would silently produce a lift that no longer looks like the original. Bounds are analytic (geometry attributes + path control points via `svg-path.ts`), and anything unmeasurable stays `null` rather than becoming a wrong number - which is what makes the paint-order safety check refuse rather than reorder. |
| `engine/src/svg-custgeom.ts` | a flat SVG the user supplied, lowered to PowerPoint custom geometry | `SVG_CUSTGEOM_MAX_CHARS = 4_000_000`, `MAX_TAGS = 40_000`, `MAX_SHAPES = 4_000`, plus 512 K attributes, 64 K style/transform and 400 K points strings | `tests/svg-custgeom.test.ts` | `svg-readers` (geometry-only and text-aware lowering) | Its own tag-stream scan, no DOM, with a group `transform` stack. Per-attribute budgets apply before split/map helpers allocate. |
| `engine/src/svg-path.ts` | the `d` attribute of any SVG a user drops or pastes, and every path string the geometry kernel receives | tokeniser is linear in the input; the callers bound the string (`geom-api.ts` at 512,000 chars, `svg-layers.ts` through its layer and candidate caps) before or as they parse | `tests/svg-path.test.ts` where present, plus every `tests/geom-*.test.ts` that starts from path data | `svg-readers` (parseSvgPath, parseSvgPathArgs) and `geom` (through every kernel entry) | A junk token ends the command it sits in rather than throwing; arcs are converted to cubics by the endpoint parameterisation, which is where an absurd radius would have become NaN, so the kernel's coordinate finiteness guard sits after it. |
| `engine/src/midi.ts` | a `.mid` a user uploaded, or fed to `scripts/ingest-midi.ts` | `MIDI_MAX_NOTES = 200_000`, `MIDI_MAX_STEPS = 1 << 15`, `MIDI_MAX_VOICES = 8` | `tests/midi.test.ts` | `midi` (event parse plus note-to-song mapping) | Header states the hardening explicitly: every offset bounds-checked, malformed track lengths clamped to the buffer, note count and step span capped. |
| `engine/src/wav.ts` | a `.wav` a user handed to `host.audio` on a headless shell | `MAX_CHANNELS = 32` | `tests/wav.test.ts` | `wav` (`parseWav`) | Chunk walking cannot run backwards or off the end regardless of declared sizes. Unknown encodings (µ-law, ADPCM, a compressed payload in a RIFF skin) are refused by name rather than misread as PCM, because misreading them yields full-scale noise that would look like a plausibly loud track. |
| `engine/src/zzfxm.ts` | a `ZzfxSong`, which on the MIDI and MOD paths derives from a user file or fetched JSON | 128 instruments × 21 params, 1,024 patterns, 4,096 sequence items, 32 channels, 32,768 steps/pattern, five-minute stereo output, 50 M mix-sample operations, 30-second individual synths and 120-second aggregate sample cache; BPM/note/numeric ranges are named public constants | `tests/zzfxm.test.ts`, `tests/zzfxm-seed-parity.test.ts` | `zzfxm` (JSON and byte-derived song objects through the real guarded renderer) | The two vendored MIT functions remain unchanged behind guarded public wrappers. Validation rejects ragged rows, invalid references and allocation-driving totals before either the upstream synth or mixer allocates. Direct `zzfxG`, `zzfxM`, and `renderZzfxm` calls all pass the same budgets. |
| `engine/src/png-unfilter.ts` | an inflated PNG IDAT or PDF `/Predictor >= 10` stream | `PNG_UNFILTER_MAX_OUTPUT_BYTES = 256 MB`; safe-integer dimension arithmetic and input-length checks precede allocation | `tests/png-unfilter.test.ts` | `png-unfilter` (filter bytes and byte-derived dimensions) | The module the house reader contract is named after: never throws, a truncated buffer or unknown filter tag or non-positive/excessive dimension returns `null`. Only 8-bit-per-component images; sub-byte depths are the caller's to reject. |
| `engine/src/steganalysis.ts` | RGBA pixels decoded from a user image | `LSB_MIN_PIXELS = 64 * 64`, `LSB_MAX_PIXELS = 4,000,000`, `LSB_P_THRESHOLD = 0.95`, three fixed prefixes; safe positive integer dimensions/product required | `tests/steganalysis.test.ts` | `watermark-analysis` | Numeric input of known length, sampled to a public work ceiling so a huge decoded canvas cannot turn an amber heuristic into main-thread CPU denial of service. |
| `engine/src/trustmark.ts` | 100 booleans recovered by the shell's ONNX decoder from a user image | `TRUSTMARK_PAYLOAD_BITS = ECC_REGION_BITS + VERSION_FIELD_BITS` (96 + 4), `LOLLY_MAGIC_BITS = 16`, `LOLLY_SCHEME_BITS = 4`, `BCH_POLYNOMIAL = 137` | `tests/trustmark.test.ts` | `watermark-analysis` | Fixed-width bit input, so there is no length field and no allocation to bomb. The target mutates the exact-length gate and bit values; a caller feeding untrusted/garbled bits never throws. |
| `engine/src/contentseal.ts` | four 256-bit vectors the shell's ONNX extractor produced from a user image | `CONTENTSEAL_REQUIRED_VIEWS = 4`, `CONTENTSEAL_MESSAGE_BITS = 256`, `CONTENTSEAL_DEFAULT_TAU = 72` | `tests/contentseal.test.ts` | `watermark-analysis` | The view count and bit width are enforced before pairwise allocation; this is also a correctness boundary because the threshold is calibrated only for 4x256. Wrong/ragged shapes return the safe negative result. |
| `engine/src/prepare-document.ts` | Local text, JSON, YAML, HAR and nested ZIP preparation sources | 32 MiB per input, 64 MiB total and expanded bytes, 1 MiB text, 300 scopes, 20,000 structured nodes/units, 64 structured levels and three archive levels; aliases never expand | `tests/prepare.test.ts` | `prepare` (inspection plus selected replacements) | Unsupported, encrypted, malformed and over-budget content is reported as uninspected and retained. ZIP member reframing preserves unknown compressed bytes. YAML parse errors do not expose source snippets. |
| `engine/src/prepare-text.ts` | Decoded preparation scalars, log text, HAR bodies and bounded declarative local rules | 1 MiB text per value, 2,000 findings per job, 100 rules with 256-character values, 4,096-character replacements; no executable rules | `tests/prepare.test.ts` | `prepare` (inspection plus selected replacements) | Patterns suggest credentials and personal data locally. Replacements are bound to source hashes and freshly derived locations. Private maps are excluded from summary reports. |
| `engine/src/geom-api.ts` | path-data (`d`) strings a tool hook hands to `host.geom` (union, offset, stroke, simplify, bounds, area, hit tests) and authored-path tokens lifted from a share URL (`decodeAuthored`) | `MAX_CHARS = 512,000` checked before any regex touches the string; `MAX_COMMANDS = 20,000`; `MAX_CURVES = 16,000` cubics after normalisation (below the kernel's 8,000-per-operand pairwise ceiling times two); `MAX_PATHS = 64` operands per boolean call; `MAX_NODES = 20,000`; `MAX_COORD = 1e9` finiteness guard on every coordinate; all reported through `limits()` | `tests/geom-api.test.ts`, `tests/geom-boolean.test.ts`, `tests/geom-offset.test.ts`, `tests/geom-stroke.test.ts`, `tests/geom-fit.test.ts`, `tests/geom-spline.test.ts`, `tests/geom-bezier.test.ts`, `tests/geom-intersect.test.ts`, `tests/spiro.test.ts` | `geom` (parse, bounds, area, winding, nearest, simplify, selfUnion, union, difference, offset, stroke, decodeAuthored) | Results, never throws: bad input comes back as `{ok:false, code}` and the kernel's `GeomLimitError` becomes code `limit`, so a caller can check `limits()` before offering an operation rather than after failing one. A non-finite coordinate produced by the kernel itself is reported as code `internal` rather than written into a template as `M NaN NaN`. The superlinear passes (boolean, offset, stroke) are the reason the char and curve ceilings sit where they do: sized above any real authored path and below where those passes stop being interactive. |
| `engine/src/dotlottie.ts` | Raw Lottie JSON and dotLottie v1/v2 archives from uploads and catalog assets | 64 MiB source, 128 MiB expanded, 32 MiB per member, 2,048 members; JSON depth 64 and 500,000 values; 2,000 layers, 100,000 keys, dependency depth 32, 1,000 images and 32 million pixels per image | `tests/lottie.test.ts`, `tests/design-lottie.test.ts`, `tests/lottie.browser.test.ts`, `tests/lottie-import.browser.test.ts` | `lottie` mutates raw JSON and both package versions; the shared ZIP reader supplies container bounds | Only embedded raster images; no external fetch, expressions, fonts, themed slots or state-machine execution. Missing/cyclic references and unsupported rendering features fail explicitly; original source bytes remain unchanged. |
| `engine/src/lottie-edit.ts` | Instance-local animationEdits revisions from Design documents and compact URLs | 256 KiB UTF-8 revision, 512 edits, JSON depth 64; source SHA-256 binding; allowlisted layer fields and property tracks; finite values, dimensions and ordered keys checked before mutation | `tests/lottie-edit.test.ts`, `tests/design-lottie.test.ts`, `tests/lottie-internal.browser.test.ts`, `tests/lottie.browser.test.ts` | `lottie-edits` mutates layer and property revisions against a pinned synthetic source | Edits apply to a clone of the admitted source. No arbitrary property paths, asset replacement or dependency mutations. Spatial path subdivision, duplicate key times and animated fill/stroke edits fail explicitly. |
| `engine/src/jxl-container.ts` | Uploaded, remote or local JPEG XL bytes and generated XML metadata boxes | 128 MiB encoded input/output; 16 MP decode, 8 MP encode, 16384 per edge; 384 MiB codec allocation budget and 768 MiB WASM ceiling; one worker, 120-second deadline; XML limited to 1 MiB and 4096 boxes | `tests/jxl.test.ts`, `tests/jxl.browser.test.ts` | `jxl` mutates real grayscale, P3, PQ, animation and auxiliary-channel fixtures through the disposable worker and bounded XML reader | Pinned libjxl 0.12.0 parses compressed images. Animation and extra channels are refused for still decode; compressed metadata is not inflated by the XML reader. Cancellation terminates the worker. Native browser support is not required. |
| `packages/node-shell/src/jxl-codec.ts` | Uploaded, remote or local JPEG XL bytes and generated XML metadata boxes | 128 MiB encoded input/output; 16 MP decode, 8 MP encode, 16384 per edge; 384 MiB codec allocation budget and 768 MiB WASM ceiling; one worker, 120-second deadline; XML limited to 1 MiB and 4096 boxes | `tests/jxl.test.ts`, `tests/jxl.browser.test.ts` | `jxl` mutates real grayscale, P3, PQ, animation and auxiliary-channel fixtures through the disposable worker and bounded XML reader | Pinned libjxl 0.12.0 parses compressed images. Animation and extra channels are refused for still decode; compressed metadata is not inflated by the XML reader. Cancellation terminates the worker. Native browser support is not required. |
| `engine/src/deep-png.ts` | Original image bytes from files, assets and remote images | 128 MiB encoded input; 8,388,608 pixels; 16384 per edge; exact decompression size; finite bounded samples; supported colour transforms only | `tests/deep-editing.test.ts`, `tests/deep-editing.browser.test.ts` | `deep-image` mutates real generated PNG16, float TIFF and compressed EXR seeds | Parsers return straight linear float samples. Unsupported profiles, pixel layouts and format variants fail explicitly. PNG verifies CRC and Adler checksums; EXR and TIFF validate every block/strip before reading. |
| `engine/src/deep-tiff.ts` | Original image bytes from files, assets and remote images | 128 MiB encoded input; 8,388,608 pixels; 16384 per edge; exact decompression size; finite bounded samples; supported colour transforms only | `tests/deep-editing.test.ts`, `tests/deep-editing.browser.test.ts` | `deep-image` mutates real generated PNG16, float TIFF and compressed EXR seeds | Parsers return straight linear float samples. Unsupported profiles, pixel layouts and format variants fail explicitly. PNG verifies CRC and Adler checksums; EXR and TIFF validate every block/strip before reading. |
| `engine/src/deep-exr.ts` | Original image bytes from files, assets and remote images | 128 MiB encoded input; 8,388,608 pixels; 16384 per edge; exact decompression size; finite bounded samples; supported colour transforms only | `tests/deep-editing.test.ts`, `tests/deep-editing.browser.test.ts` | `deep-image` mutates real generated PNG16, float TIFF and compressed EXR seeds | Parsers return straight linear float samples. Unsupported profiles, pixel layouts and format variants fail explicitly. PNG verifies CRC and Adler checksums; EXR and TIFF validate every block/strip before reading. |
| `engine/src/emoji-bundle.ts` | Imported or portable custom emoji JSON bundles | 64 MiB bundle, 32 MiB manifest, exact per-artwork digest and bounded static SVG subset; unreferenced artwork refused | `tests/emoji-author.test.ts`, `shells/web/src/bridge/emoji-portable.test.ts`, `tests/emoji-workflow.browser.test.ts` | `emoji-bundle` mutates a complete bundle with real manifest and SVG hashes | Full admission precedes installation. Authoring uses the same admission path. Redistribution permissions are checked by the storage layer. |
| `shells/web/src/lib/design-system/extract-site.ts` | Supplied HTML/CSS text, or one page returned by an existing on-device capture transport | 100,000 shared scan matches; capped colours, fonts, gradients and URLs; 2 MiB / 20 files for saved pages; 4 M HTML and 8 M CSS characters at the website adapter; 2,400 style rows and 12 values per property | `tests/brand-context.test.ts`, `tests/brand-workflow.browser.test.ts`, `shells/web/src/lib/design-system/extract-site.test.ts`, `shells/web/src/lib/design-system/sources/saved-page.test.ts` | `brand-reference` mutates HTML, CSS, transported observations and context wrappers | Saved text is parsed in a worker and never rendered or fetched. Browser measurements are optional, explicitly labelled samples; the native transport retains declared coverage. No selectors or page text are persisted as style evidence. |
| `engine/src/brand-evidence.ts` | Structured style observations from capture or a persisted design-system reference | 2,400 rows, 160 characters per value, 11 known properties, 12 retained values per property, finite bounded counts and viewport dimensions | `tests/brand-context.test.ts`, `tests/brand-workflow.browser.test.ts` | `brand-reference` | Unknown fields are discarded. Declared and computed modes stay distinct. This is evidence normalization, not a CSS renderer or a quality score. |
| `engine/src/text-source.ts` | Literal authored text and native edit ranges | 65,536 UTF-16 units per story; grapheme-boundary indexes retained under fixed cache bounds | `tests/text-document.test.ts`, `tests/text-edits.test.ts` | `text-source` mutates mixed-script, emoji and separator source through edits and readback | Unpaired surrogates and partial grapheme ranges are refused; no source normalization. |
| `engine/src/text-story-document.ts` | Authored text JSON from URL inputs, sessions, templates, clipboard and collab | 8 MiB JSON, 128 stories, 256 styles, 512 font resources, 65,536 UTF-16 units per story; fixed-depth schema and acyclic references | `tests/text-document.test.ts`, `tests/text-fragment.test.ts`, `tests/text-collab.test.ts` | `text-document` mutates structured ownership and style records; `text-source` mutates literal source | References, source boundaries, style cycles and frame ownership are checked before composition. |
| `engine/src/text-fragment.ts` | Canonical text documents carried by rich clipboard fragments | Text document admission bounds apply before copying; fresh local IDs replace imported style, font and inline identities | `tests/text-fragment.test.ts`, `tests/text-frame-clipboard.test.ts` | `text-document` imports admitted mutated fragments into an independent document | Clipboard input cannot reuse conflicting destination identifiers. |
| `engine/src/text-frame.ts` | Authored frame settings and text guide geometry from box fields | Fixed-depth schema; finite bounded sizes, insets and columns; 32 K guide characters and at most 512 curves with one traversal | `tests/text-flow.test.ts`, `tests/text-path.test.ts` | `text-frame` mutates rectangular and path frame records and samples admitted guide geometry | Path and rectangular settings are distinct; insets must leave positive text area. |
| `engine/src/vector-paint.ts` | Authored path paint JSON attached to editable vector boxes | 1 M source and attribute characters, 4,096 nodes, depth 32, 48 attributes per node, 20,000 contour references and finite matrices | `tests/vector-paint.test.ts`, `tests/text-vector.test.ts` | `vector-paint` mutates paint trees and imported static SVG | Every contour has one owner; gradient and clipping references remain local and typed. |
| `engine/src/vector-paint-import.ts` | Composed or imported static SVG lowered to editable contour paint | Static SVG admission before lowering; bounded paint tree, authored contours and finite coordinate transforms | `tests/vector-paint.test.ts`, `tests/text-vector.test.ts` | `vector-paint` mutates SVG with gradients, clipping and group opacity through import and render | Unsupported paint fails atomically; no bitmap replacement or silent silhouette reduction. |
| `engine/src/motion-cues.ts` | Authored Sequence cue JSON from saved documents and URL inputs | 128 KiB string, 256 cues, 1,024 bindings, unique ids of at most 80 characters, cycle detection and one-hour resolved timeline | `tests/motion-cues.test.ts` | `motion-cues` mutates absolute, beat, relative and maximum-depth cue graphs through compilation | Manual timing changes detach bindings; compilation validates the combined keyframe order. |
| `engine/src/sequence-samples.ts` | Explicit timeline seconds from URL, CLI and MCP export requests | 2,048-character URL value; 1-64 strictly increasing finite times from zero to one hour; every sample precedes the composition end | `tests/sequence-samples.test.ts`, `services/mcp/test/motion-samples.browser.test.ts` | `motion-params` mutates query strings through sampling validation | Invalid explicit times are refused before selecting a render tier. |
| `engine/src/motion-sampling.ts` | Shutter and preview range settings from URL, CLI and MCP requests | 1, 4, 8 or 16 shutter samples; finite angle from zero to 360 degrees; finite preview range within one hour; accumulator capped at 256 MiB | `tests/motion-sampling.test.ts`, `tests/motion-render.browser.test.ts` | `motion-params` mutates shutter and range query values | Exposure sampling clips at shot cuts and preview limits; accumulation uses premultiplied linear-light RGBA. |
| `services/mcp/src/negotiation.ts` | Untrusted JSON-RPC envelopes and mirrored HTTP headers | Transport caps bodies at 32 MiB; fixed protocol-version set; object metadata and capability checks; strict ASCII or once-decoded base64 UTF-8 names | `services/mcp/test/protocol-2026.test.ts` | `mcp-negotiation` mutates modern discovery, tool calls and legacy initialization envelopes with their HTTP mirrors | Header mismatches and unsupported versions return protocol errors before dispatch. |
| `engine/src/adobe-xml.ts` | User-supplied Adobe interchange files. Platform parsing and media lookup are supplied by the shell. | 1 MiB default, 8 MiB for timeline/IDML parts; no DTD/entities; 50,000 elements, depth 64, 128 attributes per element | `tests/adobe-interchange.test.ts` | `adobe-xml` mutates maintained independent format seeds. | XML parser is injected. Premiere permits only the inert xmeml doctype before common admission. |
| `engine/src/camera-raw-preset.ts` | User-supplied Adobe interchange files. Platform parsing and media lookup are supplied by the shell. | 1 MiB XML, Camera Raw namespace only, duplicate settings refused, numeric values checked and clamped to Darkroom ranges | `tests/adobe-interchange.test.ts` | `adobe-xml` mutates maintained independent format seeds. | Explicit values only; unmapped controls and process differences are reported. Legacy Lua presets are refused. |
| `engine/src/premiere-xml.ts` | User-supplied Adobe interchange files. Platform parsing and media lookup are supplied by the shell. | 8 MiB XML; one sequence; 128 tracks per stream; 10,000 clips; paths 8,192 characters; safe integer frame counts; 32 M preview pixels | `tests/adobe-interchange.test.ts` | `adobe-xml` mutates maintained independent format seeds. | Rational frame rates remain in the interchange model. Lolly clock conversion and unsupported effects are reported. No media path is fetched implicitly. |
| `packages/node-shell/src/adobe-psd.ts` | User-supplied Adobe interchange files. Platform parsing and media lookup are supplied by the shell. | 64 MiB input, 128 MiB decoded samples plus preview allocations, 2 MiB metadata, 1,024 layers; 512 MiB WASM memory cap | `tests/adobe-psd.test.ts`, `scripts/adobe-corpus.ts` | `adobe-psd` mutates maintained independent format seeds. | Pinned PhotoCraft parser and writer. No-edit source preservation is verified separately from visual fidelity by the optional 258-file pinned corpus. Conventional inputs retain the established engine reader. |
| `engine/src/idml-read.ts` | Unzipped user IDML parts; linked image bytes and XML parsing are supplied by the shell. | 4096 entries, 128 MiB aggregate parts, 8 MiB XML parts with common XML structure limits; 256 spreads, 10000 items per spread and 100000 total items, 32 M preview pixels; linked images capped at 64 MiB. | `tests/adobe-interchange.test.ts` | `idml-read` mutates an independently assembled ZIP through bounded extraction and the shared reader. | All spreads and page bounds are retained. Named direct styles and supported text runs are mapped; unsupported formatting is reported. External links are never fetched implicitly. |
| `packages/node-shell/wasm/layout-kernel/src/lib.rs (layout_solve)` | UTF-8 JSON bytes serialized from imported, collaborative or tool-supplied Design layout requests; the raw WASM ABI is independently callable | `MAX_BYTES = 8 MiB` for owned input and output, one live input buffer; `MAX_ITEMS = 20,000`, `MAX_TRACKS_PER_AXIS = 1,024`, `MAX_GRID_CELLS = 65,536`, `MAX_GRID_SPAN_WORK = 262,144`, `MAX_ALLOCATION_WORK = 262,144`, `MAX_SCALAR = 1e7`; serde_json default recursion limit 128; linked linear memory 32 MiB including 1 MiB stack | `tests/layout-kernel.test.ts`, `tests/layout-fuzz.test.ts`, native crate tests and pinned modern fixtures | `layout-abi` sends mutated raw bytes directly through `layout_alloc` and `layout_solve`, reaching `request.rs::solve` and its Rust `serde_json::from_slice` before the modern flex/grid routines | Eight valid seeds cover flex wrapping, fill limits, rotations, grid track types and spans. Unknown fields/enums, invalid geometry and excessive work refuse without partial output. The target releases each owned allocation in finally, propagates every refusal and WASM trap, checks finite output geometry and bounds linear memory. Direct tests verify the native goldens and recovery after malformed UTF-8, truncated JSON and schema refusal. |
| `engine/src/penpot-layout.ts (importPenpotLayouts)` | Already-decoded native binfile-v3 shape records and finalized imported boxes from a user Penpot archive | Archive admission in `design-import.ts`: `MAX_IMPORT_BYTES = 100 MiB`, `MAX_ZIP_ENTRY_BYTES = 128 MiB`, `MAX_ZIP_TOTAL_BYTES = 512 MiB`; mapped output uses Design `MAX_LAYOUT_LAYERS = 20,000`, `MAX_LAYOUT_TRACKS = 4,096`, `MAX_LAYOUT_OWNER_RECORDS = 65` and `MAX_LAYOUT_SCALAR = 1e7` | `tests/penpot-layout.test.ts`, `tests/layout-fuzz.test.ts`, `tests/design-import-modes.test.ts` | `penpot-layout` mutates writer-produced native flex/grid shape JSON through `penpotChildrenPaintOrder`, `importPenpotLayouts` and the real Design evaluator | The existing brand-import target only reaches token and page-census readers. This target covers layout-specific native properties, owner/source identity alignment, typed tracks, grid cell memberships and sizing. Findings are preserved by the mapper and converted to a controlled error by the fuzz caller, matching the web import refusal. Archive inflation itself remains in the existing shell reader. |
| `engine/src/design-layout.ts` | Authored and imported Design layer/track records supplied through `host.layout.evaluate`; grid track strings and placement records may be collaborative or tool-supplied | `MAX_LAYOUT_LAYERS = 20,000`, `MAX_LAYOUT_TRACKS = 4,096`, `MAX_LAYOUT_OWNER_RECORDS = 65`, `MAX_LAYOUT_SCALAR = 1e7`, `MAX_LAYOUT_TEXT_LAYER_CHARS = 65,536`, `MAX_LAYOUT_TEXT_DOCUMENT_CHARS = 1 M`; `MAX_LAYOUT_TRACKS_PER_AXIS = 1,024`, `MAX_LAYOUT_GRID_CELLS = 65,536`, `MAX_LAYOUT_GRID_WORK = 262,144`, `MAX_LAYOUT_PASSES = 16`, `MAX_LAYOUT_REFLOW_WORK = 320,000`; named solver/projection cache ceilings | `tests/design-layout.test.ts`, `tests/layout-fuzz.test.ts` | `penpot-layout` reaches `validateDesignLayout`, `parseLayoutTrack`, `designLayoutRequest` placement and `createDesignLayoutEvaluator` with the real WASM solver | Generated solver JSON is derived from untrusted layer values rather than being a trusted byte ingress. Ownership, enums, finite dimensions, track grammar and work are admitted before reflow. Findings retain authored rows on refusal; successful geometry is disposable and does not overwrite source intent. Source and cache equivalence have separate focused regressions. |
| `shells/web/src/views/design-guides.ts` | Authored, restored or shared Design guide text, including direct gd URL parameters and decoded z/zx state; legacy x/y JSON and compact v1/v2 wires | v1 has no local input-length or record-count cap. v2 parses JSON before considering only the first MAX_DESIGN_GUIDE_RECORDS = 1,000 array rows; IDs match [\w-]{1,80} and are unique; coordinates and rotation must be finite numbers. Packed z/zx transport separately enforces url-pack.ts MAX_TOKEN = 64 KiB and MAX_UNPACKED = 256 KiB; these limits do not apply to direct gd or codec callers. | `tests/design-guides-fuzz.test.ts`, `shells/web/src/views/design-guides.test.ts`, `tests/design-guides.browser.test.ts` | `design-guides` mutates writer-derived v1, legacy JSON and v2 text through parseDesignGuides, parseGuideRecords, both canonical writers and guideSnapTargets without mounting DOM | Eight seeds cover angles, disabled snapping, colors, 80-character IDs, finite 1e308/MAX_VALUE coordinates and more than 1,000 records. Invariants check finite snap geometry, sorted legacy axes, stable admitted identities and canonical reparse. The v1-to-v2 reparse check respects the existing first-1,000-row rule rather than imposing a v1 cap. Unexpected errors propagate; output invariant violations use the discovery runner finding class. UI creation stops at the persisted v2 capacity; standalone writers remain uncapped. |
| `engine/src/design-hierarchy.ts` | Design boxes and pages from compact URL blocks, saved .lolly documents, collaborative edits and native Penpot imports | 20,000 layers, 256 pages, 64 ancestors, IDs of 160 UTF-16 units, names of 1,000 units, finite geometry magnitudes at most 1e7, and at most 256 reported findings; indexes and migration results are weakly cached by immutable input-array revision | `tests/design-hierarchy.test.ts`, `tests/design-hierarchy-fuzz.test.ts`, `tests/design-hierarchy-import.browser.test.ts` | `design-hierarchy` mutates writer-derived legacy-group migration and nested/page document JSON through graph admission, cached indexes, ancestor/descendant traversal, bounds, flags and canonical reparse | Existing anonymous group tags remain readable. Conversion is atomic and refuses mixed board/page scopes, dynamic-layout membership or interleaved artwork with reported findings, preserving original clipping and ownership. Safe tags become explicit records without moving artwork. Explicit ownership diagnoses duplicate identities, missing parents/pages, cycles and cross-page links. Pages are editor canvases containing artboards, separate from PDF pages. Archive byte/inflation limits remain in the shell reader; scalar limits are independent of transport byte limits. |
| `packages/core/src/lolly-features.ts` | Parsed .lolly session values, project sessions, templates and bundled Design input manifests | One shared budget of 200,000 inspected or queued values; iterative traversal with cycle detection; canonical flat box records are inspected once without traversing opaque text and paint payloads; exhausting the scan conservatively requires reader 8 | `tests/design-hierarchy-lolly.test.ts`, `tests/design-hierarchy-lolly-fuzz.test.ts` | `lolly-hierarchy` mutates real web and Node writer ZIP files through the shared feature scan and semantic reader gates | This detector identifies feature requirements, not graph validity. Nonempty Design pages/pageId values, real group records and hierarchy input declarations require reader 7; group opacity, blend and non-fixed sizing require reader 8. Flat legacy tags retain existing gates. Missing, lowered or nonnumeric gates are refused after payload parsing. Model graph admission remains in design-hierarchy.ts. |
| `shells/web/src/lib/lolly-pack.ts` | Portable .lolly share ZIPs carrying saved sessions, project folders, templates and bundled tools | Existing unzip limits remain 512 MiB per part and 2 GiB total; projects admit at most 2,000 sessions and 500 folders; templates retain their existing 200-record cap; feature detection has one 200,000-value budget | `shells/web/src/lib/lolly-pack.test.ts`, `shells/web/src/lib/lolly-project.test.ts`, `tests/design-hierarchy-lolly.test.ts`, `tests/design-hierarchy-lolly-fuzz.test.ts` | `lolly-hierarchy` checks writer-derived session, project, template and tool files, logical readback and deliberate feature-gate deletion/downgrade after transport mutation | Reader 7 preserves ordinary hierarchy values and reader 8 supports group opacity, blend and sizing; integrity verification precedes semantic gate detection. Fuzz invariants cover payload preservation and gate refusal, not exhaustive ZIP admission, collaborative state or rendering fidelity. The Node reader may reject corrupt ZIP framing that the browser inflater tolerates. |
| `packages/node-shell/src/lolly-file.ts` | Portable .lolly session and reusable-tool ZIPs opened by Node shells | Engine ZIP reader caps input and total inflated bytes at 512 MiB, each entry at 256 MiB and inventory at 10,000 entries; shared semantic feature detection inspects at most 200,000 values | `tests/design-lolly.test.ts`, `tests/design-tool.test.ts`, `tests/design-hierarchy-lolly.test.ts`, `tests/design-hierarchy-lolly-fuzz.test.ts` | `lolly-hierarchy` opens web and packageDesign writer files and rejects deliberately downgraded hierarchy session, template and tool gates | Reader 7 admits canonical groups and pages in saved sessions; reader 8 admits group opacity, blend and sizing. Project folders remain an explicit app-only refusal; renovation files retain their separate reader. CRC and integrity checks precede returned payloads. Full graph validity and rendering are separate model contracts. |
| `engine/src/design-layer-ops.ts` | Stable-ID layerOperations and layerPatches from MCP and the live editor agent bridge, over existing Design layer records | The hierarchy adapter admits 20,000 layer records, 256 explicit pages, 64 ancestors and finite scalar geometry up to 1e7; operations are ordered transactions over copied rows and reject ambiguous identities, missing clone IDs, cycles and cross-domain reorder | `tests/design-layer-ops.test.ts`, `services/mcp/test/design-agent.test.ts`, `tests/design-hierarchy-fuzz.test.ts` | `design-hierarchy` mutates document JSON and derived or supplied operation/patch lists through nested duplicate, cascade remove, reparent, reorder validation and final graph admission | The existing three-argument operations API remains valid; optional page metadata provides page-definition validation. Without explicit metadata, page IDs are inferred only for ownership-coherence checks. Grid and linked-story duplication require document metadata and are refused by the row-only API; group geometry patches require the canvas transform path. These are visible refusals, not partial edits. |
| `engine/src/font-inspect.ts` | A TTF, OTF or WOFF file the person drops on Convert Font or hands to `host.fonts`, and the subsetter's output bytes when they are verified | 64 MiB input; WOFF unwrapped by font-convert.ts's capped inflate; every table read is checked against its directory length; fvar at most 64 axes and 1,024 instances; cmap format 4 scans each code point at most once, so one cmap costs at most 65,535 lookups and 32,768 ranges however its segments overlap; cmap format 12 keeps at most 1,114,112 non-overlapping groups, clamped to existing glyph ids and U+10FFFF; at most 100,000 UTF-16 units of requested text; names at most 256 characters with control characters removed | `tests/font-inspect.test.ts`, `tests/font-convert-tool.test.ts` | `font-inspect` mutates a real variable TTF, its WOFF wrapper and crafted fonts whose cmap format 4 segments and format 12 groups overlap, through `inspectFont` and `glyphsForText` | Reads cmap formats 4 and 12 only; other formats report no coverage. Reading stops at the first format 4 end code that does not ascend and at the first format 12 group that overlaps or goes backwards; the spec requires both in ascending order. WOFF2 and collections are refused by name. The subset path refuses before any work on outlines other than TrueType and on restricting fsType values (font-subset-policy.ts), then re-parses the output with the same reader. |
## Writers, not parsers

Listing these as attack surface would overstate the problem. Each consumes engine-internal IR or already-encoded bytes the shell produced, not a file a stranger sent.

| Module | What it actually does |
|---|---|
| `engine/src/tiff.ts` | Baseline TIFF **encoder**, uncompressed single strip. One export, `packTiff(pixels, opts)`. It has no reader at all. |
| `engine/src/apng.ts` | APNG **packer**. Chunk-level surgery over complete PNGs the shell encoded, one per frame. It does split input chunk streams, so the frames it reads are shell output rather than user files. |
| `engine/src/webp-anim.ts` | Animated WebP **packer** over stills from `canvas.toBlob('image/webp')`. Same shape as `apng.ts`. |
| `engine/src/emf.ts`, `eps.ts`, `dxf.ts` | The three non-SVG sinks on the vector pipeline. All three take the normalised device-px IR from `shells/web/src/bridge/svg-ir` and emit bytes or text. Text is outlined upstream, so none of them reads a font. |
| `engine/src/hdr.ts` | Pixel transform, SDR RGBA in, PQ-encoded RGBA out. `pqEncode` and `hdrBoostToPQ` only. |
| `engine/src/pptx.ts` | PPTX **builder**: OOXML scaffolding plus DrawingML serialisation. `MAX_TABLE_COLS = 128` and `MAX_TABLE_ROWS = 512` bound its own output. The reading half is `pptx-read.ts`. |
| `engine/src/pdfx.ts` | PDF/X-4 metadata authority: XMP packet strings and descriptor objects. Interpolated values get an XML escape (`esc`), which is output hygiene, not parsing. `N_ALLOWED = new Set([1, 3, 4])` validates a caller's channel count. |
| `engine/src/zip-crypto.ts` | ZipCrypto and WinZip AES-256 **encryption** plus zip framing, over bytes fflate already compressed in the shell. All randomness is injected via `opts.rng`. |
| `engine/src/pdf-crypto-r6.ts` | PDF R6 AES-256 `/Encrypt` value computation. Deterministic given its inputs; the shell supplies the file key, all four salts, the Perms tail and every IV. |
| `engine/src/packbits.ts` | PackBits RLE, both directions, over byte buffers `psd.ts` and the TIFF path hand it. The decoder is defensive on every input and never throws (a truncated packet or either-side overrun returns `-1`), so it is covered wherever its callers are fuzzed rather than needing a container walk of its own. `tests/packbits.test.ts`. |
| `engine/src/metadata.ts` | Assembles the provenance record from profile plus manifest. No format or byte knowledge. |

## The standard to follow

`engine/src/pptx-read.ts` is the module to copy when you add a parser. Its caps block sits at lines 156 to 167, immediately after the public types and before the first walker, so a reviewer meets the bounds before the code they bound:

```ts
// ─── hardening caps ──────────────────────────────────────────────────────────

const MAX_PART_BYTES = 24 * 1024 * 1024; // skip parsing a part bigger than this
const MAX_PART_CHARS = 16 * 1024 * 1024;
const MAX_SLIDES = 2000;
const MAX_NODES_PER_SLIDE = 8000;
const MAX_GROUP_DEPTH = 16;
const MAX_PARAS = 4000;
const MAX_RUNS_PER_PARA = 4000;
const MAX_TABLE_ROWS = 2000;
const MAX_TABLE_COLS = 512;
const MAX_TEXT_LEN = 200_000; // per run/cell text clamp
const MAX_DFS_VISITS = 200_000; // bound any descendant search
const MAX_COORD = 1e11; // EMU magnitude clamp (slide width is ~1.2e7)
```

Four properties make this the exemplar rather than merely a long list.

**The threat model is written down.** The module header carries a `SECURITY` section that says it outright: "a hostile zip is the threat model, same as PDF". It then states the consequences as commitments, not hopes: every part is size-capped before parsing, slide, node, paragraph, run and table counts are capped, group-shape recursion is depth-capped and "a malformed or hostile part NEVER throws, we return what parsed and skip the rest".

**Every cap has a unit and a real-world comparison.** `MAX_COORD = 1e11` carries `// EMU magnitude clamp (slide width is ~1.2e7)`, which tells the next reader the cap sits four orders of magnitude above anything legitimate. A number with no comparison point is a number nobody can safely change later.

**It says what it delegates.** "XML entity-expansion (billion-laughs) is the injected parser's responsibility, but we additionally bound every DFS by a visited-node counter so a pathologically deep/wide tree can't hang us." Naming the boundary is what lets a reviewer check the other side of it, and adding the counter anyway is defence in depth for the case where a shell injects a parser without those protections.

**The contract is one sentence and it is testable.** Never throw, return what parsed. `tests/pptx-read.test.ts` asserts it, and the `pptx-read` fuzz target asserts it against mutated real decks.

`engine/src/icc.ts` is the same discipline applied to a harder format, and its caps block header is worth borrowing verbatim: "Every one of these bounds something a hostile file declares", followed by the largest value each cap was measured against across forty real profiles.

## Known gaps

Verified against `ALL_TARGETS` in `tests/fuzz/targets.ts` and against each module's own source. An earlier pass over this ground over-counted, because it treated the format writers as parsers.

Every row is now mapped to a registered mutation target; there are no parser-assurance waivers. This is coverage accounting, not a proof of semantic correctness or exhaustive state exploration.

**Named second-order bounds.** `pdf-map.ts` now owns the page-wide character, token and emitted-node budgets that bound every current consumer. The derived parsers still keep their own narrower shape-specific limits (`pdf-artwork.ts` candidates, `pdf-svg.ts` paths/clips, `pdf-redaction.ts` covers) because their public functions can also receive hand-built `PdfNode[]` values.

## Adding a new parser

Four requirements. All four, before the module merges.

1. **Declare named `MAX_*` constants in one block, near the top, before the walkers.** One constant per thing a hostile input can declare: byte length, element or record count, nesting depth, coordinate magnitude, text length. Each gets a comment with its unit and the largest legitimate value you measured. Never inline a bare number into a loop bound. If a bound is genuinely structural rather than numeric, state that in the header the way `svg-path.ts` and `der-read.ts` do, and explain why.

2. **Bounds-check before reading, not after.** The invariant `der-read.ts` states is the one to internalise: check a multi-byte length head *before* consuming its bytes, because an out-of-range `Uint8Array` read yields `undefined`, `undefined` NaN-poisons the arithmetic and a NaN comparison makes the guard that follows silently false. That is how a truncated TLV gets accepted.

3. **Pick a contract, state it in the header and honour it everywhere.** There are three legitimate contracts in this codebase and the choice follows from what the caller does with the answer.
   - *Best-effort reader, never throws, returns `null` or a partial result.* The house contract, named after `png-unfilter.ts` and stated most fully in `icc.ts` and `file-metadata.ts`. Correct when a failure means "we could not read this", and the UI degrades gracefully.
   - *Throws promptly on malformed input.* `der-read.ts` and the CBOR decoder in `c2pa-extract.ts`. Correct when a silent mis-parse would be worse than an error, which is the case for anything feeding a cryptographic check.
   - *Fails closed by throwing.* `strip-metadata.ts`. Correct when returning the input unchanged would be presented to a user as a safety guarantee. A privacy or security control must never fail open.

4. **Add a same-name test file, register a fuzz target, and register the parser.** `tests/<module>.test.ts` covers the truncation, garbage and cap-tripping cases by hand. Then add a `FuzzTarget` to `tests/fuzz/targets.ts` with a small seed corpus of *valid* inputs, built where possible from the engine's own writer so the seeds are real container layouts, an `invoke()` that does not swallow errors (the runner classifies a thrown validation error as the desired behaviour, and a hang or allocation blow-up as a finding) and an entry in `ALL_TARGETS`. Add the parser's input, bounds, direct tests, fuzz coverage, and notes to `security/parser-assurance.json`, then run `pnpm run build:parser-inventory`; CI rejects stale generated Markdown. Note the entry-point comment convention at the top of the fuzz file, which records exactly which function each target hits. Any input a discovery run finds goes into `tests/fuzz/regressions/` as a `.bin`, named for the module and the failure, and `tests/fuzz-regression.test.ts` will replay it on every `pnpm test` from then on.

---

Code ([MPL 2.0](https://www.mozilla.org/en-US/MPL/2.0/)) · Content ([CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)) · [lolly.tools](https://lolly.tools)
