Skip to content

fix(uploads): bound the HEIF fallback decode by declared pixels, not just bytes - #6456

Merged
waleedlatif1 merged 3 commits into
stagingfrom
heif-pixel-guard
Aug 8, 2026
Merged

fix(uploads): bound the HEIF fallback decode by declared pixels, not just bytes#6456
waleedlatif1 merged 3 commits into
stagingfrom
heif-pixel-guard

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

  • refuse a HEIF whose container declares more pixels than the ceiling, before the decoder allocates for it — the fallback decoder sizes its buffer from the declared dimensions up front, so a file that never decodes still costs the memory
  • the existing 20MB byte ceiling does not bound a declared raster; a small container can name dimensions far above it
  • read the dimensions with heic-decode's all(), which reports each image's size while leaving the decode for decode()
  • 100MP clears every phone camera (a 48MP iPhone still is 8064x6048)
  • promotes heic-decode from a transitive dependency of heic-convert to a direct one at the same version, since the code now imports it

Type of Change

  • Bug fix

Testing

Added unit tests for the ceiling (including the multi-image case and that the handle is never asked to decode), plus one pinning that all is a named ESM export — a mocked test alone could not catch that regressing. Verified the guard tests fail without the guard. lib/uploads (408), lib/copilot and app/api/files (1658) suites pass; type-check, lint and api-validation clean.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

…just bytes

The WebAssembly fallback allocates width * height * 4 up front, taking the size
straight from the container and building the buffer before the codec is asked for
anything — so a file that never decodes still costs the memory. Only a 20MB byte
ceiling stood in front of it, and bytes do not bound a declared raster: a small
container can name dimensions up to libheif's own default of ~1.07e9 pixels, about
4.3GB as RGBA.

Read the declared dimensions with `heic-decode`'s `all()`, which parses the
container and reports each image's size while leaving the decode for `decode()`,
and refuse above 100MP. That caps the allocation near 400MB and clears every phone
camera — a 48MP iPhone still is 8064x6048.

`heic-decode` was already present as a transitive dependency of `heic-convert`;
this promotes it to a direct one at the same version, since the code now imports it.
Local types cover only the surface used, and a test pins that `all` really is a
named ESM export — a CJS `module.exports = one` need not surface it, and if it
stopped, the check would throw, get swallowed by the catch, and quietly stop
guarding with mocked tests still green.
@waleedlatif1
waleedlatif1 requested a review from a team as a code owner August 8, 2026 22:10
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 8, 2026 10:21pm

Request Review

@cursor

cursor Bot commented Aug 8, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes upload preview transcoding and memory bounds on a tenant-facing path; behavior for normal phone HEIC stays allowed, but oversized declared dimensions are refused and the new dispose path must not leak or swallow guard failures.

Overview
The HEIF→JPEG fallback in transcodeHeicToJpeg now rejects containers whose declared dimensions exceed 100MP before heic-convert allocates a raster—a small file can still name huge width × height, which the existing 20MB byte cap does not prevent.

The guard uses heic-decode's all() to read each image’s size without decoding, then dispose() on the returned handles so libheif/WASM context is not leaked on every preview. heic-decode is added as a direct dependency (same version as before via heic-convert), with a local heic-decode.d.ts.

Tests add a mocked pixel-ceiling suite plus pins that all exposes dimensions without decode() and remains a named ESM export—so a silent guard regression does not stay green behind mocks alone.

Reviewed by Cursor Bugbot for commit df71c97. Configure here.

@greptile-apps

greptile-apps Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a declared-pixel ceiling before HEIF fallback decoding and promotes heic-decode to a direct dependency. The latest changes also complete the decoder-resource cleanup and pin the relied-upon all() runtime contract.

  • Rejects any HEIF declaring an image above the 100-megapixel ceiling before fallback conversion.
  • Disposes the live decoder handles on successful, refused, and dimension-read failure paths.
  • Adds focused guard, cleanup, export-shape, and pre-decode dimension tests.
  • Pins heic-decode 2.1.0 directly in the application manifest and lockfile.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported dimensions issue was invalidated by the verified all() contract, and the decoder-handle leak is fixed by unconditional disposal after dimension inspection.

Important Files Changed

Filename Overview
apps/sim/lib/uploads/server/heic.ts Adds the pre-decode pixel guard and now reliably disposes the temporary decoder handles through a finally block.
apps/sim/lib/uploads/server/heic-pixel-guard.test.ts Covers oversized, multi-image, boundary, ordinary-photo, no-decode, and decoder-handle cleanup behavior.
apps/sim/lib/uploads/server/heic.test.ts Pins the real library mapping that exposes dimensions before decoding and verifies the named ESM export.
apps/sim/types/heic-decode.d.ts Declares the directly used lazy-handle API and makes its required disposal operation explicit.
apps/sim/package.json Promotes the newly imported heic-decode package to an exact direct dependency.
bun.lock Records the direct dependency without introducing a duplicate heic-decode resolution.

Sequence Diagram

sequenceDiagram
  participant Upload
  participant Guard as HEIF pixel guard
  participant Decoder as heic-decode all()
  participant Convert as heic-convert
  Upload->>Guard: HEIF buffer
  Guard->>Decoder: Parse declared image dimensions
  Decoder-->>Guard: Handles with width, height, dispose
  alt Any image exceeds 100MP
    Guard->>Decoder: dispose()
    Guard-->>Upload: null
  else Dimensions are within ceiling
    Guard->>Decoder: dispose()
    Guard->>Convert: Convert primary image to JPEG
    Convert-->>Upload: JPEG buffer
  end
Loading

Reviews (3): Last reviewed commit: "test(uploads): pin that all() reports di..." | Re-trigger Greptile

Comment thread apps/sim/lib/uploads/server/heic.ts Outdated
Comment thread apps/sim/lib/uploads/server/heic.ts
all() hands back live libheif handles and leaves freeing them to the caller —
the default export does it in a finally, this path did not. Every HEIF preview
would have leaked the decoder context on the WebAssembly heap.

Copy the two numbers out, dispose in a finally so a throw mid-read still frees,
and declare dispose as required on the returned array so a caller cannot omit it.
The pixel guard is only worth anything if the declared size is available up
front. Were dimensions to move behind decode() — as they already are on the
default export — width * height would silently become NaN and the check would
stop rejecting anything, with every mocked test still green.

Drives the real mapping with a stub libheif, so the contract is exercised
without needing a HEVC encoder to build a valid container.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit df71c97. Configure here.

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit df71c97. Configure here.

@waleedlatif1
waleedlatif1 merged commit ff1ea21 into staging Aug 8, 2026
30 checks passed
@waleedlatif1
waleedlatif1 deleted the heif-pixel-guard branch August 8, 2026 22:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant