Parser inventory

signed by Lollyparser-inventory.htmlHTMLAI generatedgenerated by Claude직접 확인하기Get the signed filepixels, not shapes14 KB

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:

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 37: c2pa-verify, cbor, media-sniff, pdf-map, pdf-derived, x509, file-metadata, strip-metadata, video-meta, data-import, brand-import, 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, xcf, docx-read, svg-readers, keyframes, midi, zzfxm, radiance, seal, png-unfilter and watermark-analysis. 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 npm test glob; node tests/fuzz/run.ts is the standalone soak.

The table is enforced by npm 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

ModuleInput sourceBounds enforcedDirect test fileFuzz targetNotes
engine/src/pdf-map.tsPDF or Illustrator .ai content stream, decoded by the shell's pdf-lib pass; plus each font's /ToUnicode CMap textPDF_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 ceilingstests/pdf-map.test.tspdf-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.tsthe PdfNodes pdf-map.ts produced for one page4,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 limitstests/pdf-svg.test.tspdf-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.tsthe same interpreted text nodes, reassembled into reading order4,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 pagestests/pdf-text.test.tspdf-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.tsinterpreted PDF nodes, clustered into candidate logosPDF_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 = 12tests/pdf-artwork.test.tspdf-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.tsinterpreted PDF nodes, looking for text under filled bars4,000 nodes/page, 64 covering shapes/run, 1 M chars/run and 10,000 pages/documenttests/pdf-redaction.test.tspdf-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.tsan unzipped .pptx part map (`Record<path, Uint8Array \string>`) plus an injected XML parserMAX_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 = 1e11tests/pptx-read.test.tspptx-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.
engine/src/pptx-patch.tsthe same hostile part map, rewritten in place for a rebrandMAX_PART_CHARS = 32 Mtests/pptx-patch.test.tspptx-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.tsan 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 = 8tests/docx-read.test.tsdocx-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.tsan ICC profile lifted out of a user-supplied JPEG, PNG, PDF or TIFFMAX_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 = 40tests/icc.test.ts, tests/icc-real-profiles.test.tsicc (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 names the largest value seen across the forty-odd real profiles. Apple's over-reporting gamt tag is handled by name.
engine/src/file-metadata.tsraw bytes of any uploaded raster, vector or MP4 or QuickTime videoMAX_FIELDS = 64, MAX_VALUE_CHARS = 2048, MAX_TEXT_SCAN = 16 MB, MAX_GIF_BLOCKS = 1_000_000, MAX_MPF_IMAGES = 16, MAX_GAINMAP_SNIFF = 8192tests/file-metadata.test.tsfile-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.tsthe same uploaded bytes, for the clean-copy pathno numeric caps; the invariants are structural (STRIPPABLE, PNG_STRIP, DROP_EL_PREFIX, DROP_EL_NAME, SPACE_SENSITIVE, DROP_XMLNS)tests/strip-metadata.test.tsstrip-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.tsthe marker structure of any JPEG - one a user uploaded, and one our own export pipeline is about to stampJPEG_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 D0FF D7 restarts) rather than searchedtests/jpeg-segments.test.tsjpeg-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). Split contract by side, on purpose: 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.tsthe bytes of an uploaded or piped Radiance .hdr (RGBE) fileRADIANCE_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 ontests/radiance.test.tsradiance (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.tsthe MP Index IFD of any JPEG being C2PA-stamped - ours, or one a user uploadedevery 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 readtests/gainmap-jpeg.test.ts, tests/c2pa-gainmap.test.tsjpeg-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.tsheader prefix of any uploaded raster or videonone named; every read is an explicit offset + len > bytes.length check and the GIF/PNG walks bail at fixed countstests/media-sniff.test.tsmedia-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/psd.tsa user's whole Photoshop PSD/PSB, on the layered-import pathMAX_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 allocationtests/psd.test.tspsd (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.
engine/src/xcf.tsa user's whole GIMP XCF, same pathMAX_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.tsxcf (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.tsa dropped or pasted Windows .bmpBMP_MAX_DIM = 0x7fff, BMP_MAX_PIXELS = 64 Mpx; the 54-byte header, pixel offset and pixelOffset + stride * height are range-validated before allocation/dereftests/bmp.test.tsraster-decodeDecoder half of an encoder/decoder pair. A typed BmpUnsupportedError names the refused class 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.tsa dropped Windows .ico/.cur, on the icon-import pathICO_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-validatedtests/ico-decode.test.tsraster-decodePicks 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.tsan animated PNG a user dropped, split to per-frame stillsAPNG_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-validatedtests/apng-decode.test.tsraster-decodeChunk-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.tsan animated WebP a user dropped, same pathWEBP_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 enforcedtests/webp-anim-decode.test.tsraster-decodeSame 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 pathMAX_SNIFF_READ = 64 KB per read, MAX_IFD_ENTRIES = 512, MAX_JPEG_SEGMENTS = 512; every read is an explicit bounds-checked sliceshells/web/src/lib/image-sample.test.tsdepth-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 DarkroomCUBE_MAX_N = 129, TDL_MAX_N = 65 (grid size), declared in the hooks beside the parsersnone of its own; the fuzz target is the coveragelut-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.tsa finished MP4 or WebM from MediaRecorder, or a user's video on the ingredient pathnone namedtests/video-meta.test.tsvideo-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.tsthe JUMBF manifest store inside any user file, and the CBOR claims inside itMAX_CBOR_DEPTH = 64tests/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.tsc2pa-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.tsthe 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 sentnone 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 aggregatec2pa-containers (sniffFormatattachC2paStore, 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.tsa whole user file, plus the COSE signature and X.509 chain inside its manifestMAX_CHAIN_INTERMEDIATES = 8tests/c2pa-verify.test.ts, tests/c2pa-trust.test.ts, tests/c2pa-verdict.test.ts, tests/c2pa-c2patool-conformance.test.tsc2pa-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.tsDER/ASN.1 straight out of attacker-controlled files, shared by c2pa-verify.ts, x509.ts and seal.tsnone named; the invariant is that every multi-byte length head is bounds-checked before its bytes are readtests/der-read.test.tsder-read (walkDer + ecdsaDerToRaw), and indirectly through the x509 and c2pa-verify targetsThe 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.tscertificate DER from a manifest's x5chain, or from the CA servicenone named beyond the DER walker's own checkstests/x509.test.tsx509 (parseCertificate)Both a writer (generateSigner, generateCaRoot, issueLeafCert) and a reader. The read side is the exposed half.
engine/src/seal.tsraw bytes of any file, scanned for an embedded SEAL record64 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 constantstests/seal.test.tsseal (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.tsa user's CSV or JSON file, read to text by the shellMAX_IMPORT_CHARS = 8 M, DEFAULT_ROW_LIMIT = 1000 (both exported)tests/data-import.test.tsdata-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.tsa Tokens Studio or DTCG token document, a per-set file tree or a .penpot project zipBRAND_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 = 64tests/brand-import.test.tsbrand-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.tsa .tar (or, gunzipped, .tar.gz) a user handed to an import pathUSTAR 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 inflationtests/tar-read.test.ts, tests/gzip-tar.test.tstar-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.tsan .epub of approved body copy, on the brand-boilerplate ingest pathshared 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 refusedtests/epub-read.test.ts, tests/ingest-epub.test.tsepub-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.tsthe z and zx params of any shared linkMAX_TOKEN = 64 KB, MAX_UNPACKED = 256 KB; PBKDF2_ITERATIONS = 210_000, ENC_SALT_BYTES = 16, ENC_IV_BYTES = 12 on the encrypted varianttests/url-pack.test.tsurl-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.tsa box's kf field - free text, settable from a hand-edited share URL256 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 rangetests/keyframes.test.tskeyframes (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/svg-colors.tsraw SVG source text from an uploaded or fetched assetSVG_COLORS_MAX_CHARS = 4_000_000, SVG_COLORS_MAX_MATCHES = 100_000, shared by both passestests/svg-colors.test.tssvg-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.tsa 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 themtests/svg-layers.test.ts, plus the rendered half in tests/svg-lift-identity.browser.test.tssvg-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.tsa flat SVG the user supplied, lowered to PowerPoint custom geometrySVG_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 stringstests/svg-custgeom.test.tssvg-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.tsan SVG path d string, from artwork, a template or a URL paramSVG_PATH_MAX_CHARS = 400_000, SVG_PATH_MAX_ARGS = 100_000, SVG_PATH_MAX_SEGMENTS = 100_000, SVG_PATH_MAX_SUBPATHS = 10_000tests/svg-path.test.tssvg-readers (number and path normalisation)A linear single-pass tokenizer with no recursion; allocation ceilings are enforced while scanning numbers and before appending each emitted subpath/segment. Any budget breach rejects the whole path rather than returning partial geometry.
engine/src/midi.tsa .mid a user uploaded, or fed to scripts/ingest-midi.tsMIDI_MAX_NOTES = 200_000, MIDI_MAX_STEPS = 1 << 15, MIDI_MAX_VOICES = 8tests/midi.test.tsmidi (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.tsa .wav a user handed to host.audio on a headless shellMAX_CHANNELS = 32tests/wav.test.tswav (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.tsa ZzfxSong, which on the MIDI and MOD paths derives from a user file or fetched JSON128 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 constantstests/zzfxm.test.ts, tests/zzfxm-seed-parity.test.tszzfxm (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.tsan inflated PNG IDAT or PDF /Predictor >= 10 streamPNG_UNFILTER_MAX_OUTPUT_BYTES = 256 MB; safe-integer dimension arithmetic and input-length checks precede allocationtests/png-unfilter.test.tspng-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.tsRGBA pixels decoded from a user imageLSB_MIN_PIXELS = 64 * 64, LSB_MAX_PIXELS = 4,000,000, LSB_P_THRESHOLD = 0.95, three fixed prefixes; safe positive integer dimensions/product requiredtests/steganalysis.test.tswatermark-analysisNumeric 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.ts100 booleans recovered by the shell's ONNX decoder from a user imageTRUSTMARK_PAYLOAD_BITS = ECC_REGION_BITS + VERSION_FIELD_BITS (96 + 4), LOLLY_MAGIC_BITS = 16, LOLLY_SCHEME_BITS = 4, BCH_POLYNOMIAL = 137tests/trustmark.test.tswatermark-analysisFixed-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.tsfour 256-bit vectors the shell's ONNX extractor produced from a user imageCONTENTSEAL_REQUIRED_VIEWS = 4, CONTENTSEAL_MESSAGE_BITS = 256, CONTENTSEAL_DEFAULT_TAU = 72tests/contentseal.test.tswatermark-analysisThe 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.

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.

ModuleWhat it actually does
engine/src/tiff.tsBaseline TIFF encoder, uncompressed single strip. One export, packTiff(pixels, opts). It has no reader at all.
engine/src/apng.tsAPNG 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.tsAnimated WebP packer over stills from canvas.toBlob('image/webp'). Same shape as apng.ts.
engine/src/emf.ts, eps.ts, dxf.tsThe 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.tsPixel transform, SDR RGBA in, PQ-encoded RGBA out. pqEncode and hdrBoostToPQ only.
engine/src/pptx.tsPPTX 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.tsPDF/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.tsZipCrypto 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.tsPDF 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.tsPackBits 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.tsAssembles 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:

// ─── 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 names it: "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, say so in the header the way svg-path.ts and der-read.ts do, and explain why.
  1. 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.
  1. 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.
  1. 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 npm 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 npm test from then on.