fix(engine): stop the PNG walk at cICP, anchor IHDR, use native crc32 - #2912
fix(engine): stop the PNG walk at cICP, anchor IHDR, use native crc32#2912vanceingalls wants to merge 1 commit into
Conversation
Three defects in the PNG metadata fallback, all introduced when the cICP early return became an accumulator. Corrupt trailing chunk nulls a good result. cICP must precede IDAT, so continuing past it only visits chunks this parser ignores — while making whole-file integrity a precondition for returning anything. A truncated or bad-CRC chunk after cICP in an otherwise-good HDR PNG returned null, and extractMediaMetadata then re-throws the ffprobe error it had swallowed instead of using the fallback it just computed: the render dies on a host without FFmpeg, or grades SDR on a build that does not decode cICP. Now stops once dimensions and colour are known. A second IHDR overwrote the dimensions. PNG permits exactly one, first, but nothing enforced that here — a trailing [IHDR 1x1] replaced a real 3840x2160 and the producer laid out a one-pixel image. Anchored to the first. The length guard was also `>= 8` against a spec length of 13, which accepted a truncated header and read height out of the CRC bytes. crc32 was hand-rolled bit-at-a-time and fed a Buffer.concat per chunk. Since the walk no longer stops early it CRC'd whole files: 210 ms on a 12 MiB PNG, 647 ms on a 35 MiB 4K one, synchronously on the event loop, plus ~11 MB of garbage per parse from concatenating a 4-byte type tag onto every chunk. node:zlib's crc32 is native and takes a running seed, so type and data hash in sequence with no copy. 210.28 ms -> 1.291 ms. Tests: 5 regressions — corrupt-after-cICP, truncation after cICP, second IHDR, short IHDR, and that a corrupt IHDR/cICP still rejects. Reverting the break or the anchor fails 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
miga-heygen
left a comment
There was a problem hiding this comment.
Stack 1/6: PNG walk fixes + native CRC — Review
Clean fix, no concerns.
Verified
-
chunkCrc32(chunkType, chunkData)usesnode:zlib's nativecrc32with a running seed — noBuffer.concatallocation, hashes type then data in sequence. Engine'spackage.jsonrequiresnode >= 22, which is whenzlib.crc32landed. Correct. - IHDR anchor:
chunkLen >= 13(spec length, was 8 — truncated IHDR used to read height out of CRC bytes), andwidth === 0 && height === 0guard rejects subsequent IHDRs. Both conditions are necessary. - Early break:
if (width > 0 && height > 0 && colorSpaceFromCicp !== null) break— stops the walk the moment everything this parser extracts is found. cICP must precede IDAT (enforced by the!seenIdatguard above), so anything after it is chunks this parser ignores. Continuing made whole-file integrity a precondition for returning anything. - 5 regression tests cover the exact failure modes: corrupt-after-cICP, truncation-after-cICP, second IHDR, short IHDR, corrupt IHDR/cICP itself. Each test is self-contained with explicit byte payloads.
- The hand-rolled CRC removal leaves no dead code.
Ships clean.
miguel-heygen
left a comment
There was a problem hiding this comment.
Reviewed exact head 2af3f4d0ed9fac2fadc1266bcadde04e6513d800. The CRC chaining, early cICP exit, IHDR anchoring, and corruption tests are correct, and exact-head CI is terminal green. One compatibility blocker remains:
P1 — the named node:zlib crc32 import breaks runtimes the package still declares supported. zlib.crc32 was added in Node 22.2.0, but both packages/engine/package.json and packages/cli/package.json declare node >=22, and runtimeVersion.test.ts explicitly accepts 22.0.0. On Node 22.0/22.1, importing ffprobe.ts fails at module evaluation because node:zlib has no crc32 export—before any PNG path runs. Raise the enforced/package runtime floor consistently to >=22.2.0, or retain a compatible fallback, and add a minimum-supported-runtime import smoke. Official API history: https://nodejs.org/download/release/v22.17.0/docs/api/zlib.html#zlibcrc32data-value
No other P0-P2 findings. No merge performed.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Independent read at 2af3f4d, third perspective following @miga-heygen. Agree with their verified matrix — the IHDR >= 13 + width === 0 && height === 0 double-guard is both necessary, the early break at cICP is documented as a correctness fix (not just an optimisation, since it makes whole-file integrity a precondition for returning anything), and the CRC swap is byte-correct with crc32(chunkData, crc32(Buffer.from(chunkType, "ascii"))) using the running-seed API cleanly.
Only one angle to add:
Nits
node:zlib.crc32minor-version dependency.crc32was added tonode:zlibin Node 22.2.0 (May 2024) — engine's"node": ">=22"engines constraint permits 22.0.x and 22.1.x, where this would throw at import time. CI is onactions/setup-node@v4withnode-version: 22, which resolves to latest 22.x on GitHub-hosted runners, so no CI risk. But a developer or CI runner pinned to an early 22 minor could hit it. Tightening the engines to">=22.2"(or the current LTS 22.11+) would make it a load-time error atnpm installrather than a runtime throw. Non-blocking — real-world exposure is tiny.
Otherwise the ship-list stands. LGTM from my side.
— Review by Rames D Jusso
Stack 1/6. Follow-up to #2740, which is already merged.
What
Three defects in the PNG metadata fallback, all consequences of #2740 turning the cICP early
returninto an accumulator.A corrupt trailing chunk nulls a good result. cICP must precede IDAT (the parser enforces
!seenIdat), so continuing past it only visits chunks this parser ignores — while making whole-file integrity a precondition for returning anything. A truncated or bad-CRC chunk after cICP in an otherwise-good HDR PNG now returnsnull.That is not a quiet degradation.
extractMediaMetadatare-throws the ffprobe error it had swallowed:So on a host without FFmpeg the render dies on a file the pure-JS fallback used to handle; on a build that reports colour but does not decode cICP, the still grades SDR. A/B: OLD returned
{3840x2160, bt2020/smpte2084}, NEW returnsnull.A second IHDR overwrote the dimensions. PNG permits exactly one, first, but nothing enforced that — cICP has a
!seenIdatguard, IHDR had none.[IHDR 3840x2160][cICP][IDAT][IHDR 1x1][IEND]gave OLD 3840x2160, NEW 1x1, and the producer laid out a one-pixel image. The length guard was also>= 8against a spec length of 13, so a truncated header was accepted and height read out of the CRC bytes.crc32 became a whole-file cost. Hand-rolled bit-at-a-time, fed a
Buffer.concatper chunk purely to prepend a 4-byte type tag. Since the walk no longer stopped early it hashed entire files, synchronously on the event loop:Plus ~11.5 MB of garbage per parse from the concat.
node:zlib'scrc32is native and takes a running seed, so type and data hash in sequence with no copy: 210.28 ms → 1.291 ms.Verification
5 regressions — corrupt-after-cICP, truncation after cICP, second IHDR, short IHDR, and that a corrupt IHDR/cICP still rejects. Reverting the break or the anchor fails 3. Engine suite: 1280 pass.
🤖 Generated with Claude Code