diff --git a/src/runtime/image/Image.rs b/src/runtime/image/Image.rs index 347221cc8ad1..6146936bb977 100644 --- a/src/runtime/image/Image.rs +++ b/src/runtime/image/Image.rs @@ -394,6 +394,8 @@ fn source_from_js( out.truncate(r.written); return Ok(Source::Owned(out)); } + // Same check as `Bun.file()`; the worker opens this as a C string. + crate::node::types::Valid::path_null_bytes(s, global)?; return Ok(Source::Path(ZBox::from_bytes(s))); } if let Some(ab) = value.as_array_buffer(global) { diff --git a/src/runtime/image/README.md b/src/runtime/image/README.md index 293b2b8a1497..b2bbc512b5b6 100644 --- a/src/runtime/image/README.md +++ b/src/runtime/image/README.md @@ -55,6 +55,15 @@ The codecs themselves are vendored via `scripts/build/deps/{libjpeg-turbo,libspn codecs return `Encoded` with the right `free`, not a default_allocator slice. - The `max_pixels` guard fires **after the header read, before the RGBA alloc** in every codec. New codecs must do the same. +- `probe()` reports the dimensions the decoder will produce, so `metadata()` + and the terminals agree on what `max_pixels` rejects. For GIF that is the + first Image Descriptor, not the Logical Screen Descriptor. +- Metadata is bounded independently of `max_pixels`. An ICC profile over + `MAX_ICC_PROFILE_BYTES` is dropped at decode before it is copied out of the + codec (JPEG cannot carry more than 255 × 65519 bytes anyway), and libspng + runs with chunk limits so an iCCP/zTXt/iTXt that inflates past the cap + fails the decode instead of filling memory. New codecs must cap whatever + they inflate or copy out of the container the same way. - `image_resize.cpp` must stay in `noUnify` (see `scripts/build/unified.ts`) — highway's `foreach_target.h` has a TU-wide include guard that breaks with two highway TUs in one bundle. diff --git a/src/runtime/image/codec_gif.rs b/src/runtime/image/codec_gif.rs index b309ee95d6eb..6a434fa3b314 100644 --- a/src/runtime/image/codec_gif.rs +++ b/src/runtime/image/codec_gif.rs @@ -129,7 +129,23 @@ impl Dict { } } -pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result { +/// Everything in front of the first frame's LZW data. `width`/`height` are the +/// Image Descriptor's (the decoded size), not the Logical Screen Descriptor's. +pub(crate) struct Header { + pub(crate) width: u32, + pub(crate) height: u32, + interlace: bool, + /// Local colour table if the frame has one, else the global one. + color_table: core::ops::Range, + min_code: u8, + /// Transparency index from the most recent Graphics Control Extension. + transparent: Option, + /// Offset of the first LZW sub-block. + lzw_off: usize, +} + +/// Walk to the first Image Descriptor, skipping extensions. No LZW is read. +pub(crate) fn parse_header(bytes: &[u8]) -> Result { // ── header + LSD ─────────────────────────────────────────────────────── if bytes.len() < 13 || !(&bytes[0..6] == b"GIF89a" || &bytes[0..6] == b"GIF87a") { return Err(codecs::Error::DecodeFailed); @@ -146,11 +162,7 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result bytes.len() { return Err(codecs::Error::DecodeFailed); } - let gct: &[u8] = if has_gct { - &bytes[13..][..(gct_size as usize) * 3] - } else { - &[] - }; + let gct: core::ops::Range = if has_gct { 13..i } else { 0..0 }; let mut trns: Option = None; // transparency index from the most recent GCE @@ -203,18 +215,17 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result = if has_lct { if i + lct_size * 3 > bytes.len() { return Err(codecs::Error::DecodeFailed); } - let s = &bytes[i..][..lct_size * 3]; + let r = i..i + lct_size * 3; i += lct_size * 3; - s + r } else { gct }; - if ct.is_empty() { + if color_table.is_empty() { return Err(codecs::Error::DecodeFailed); // no palette at all } @@ -223,7 +234,15 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result return Err(codecs::Error::DecodeFailed), } @@ -231,6 +250,21 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result Result { + let hdr = parse_header(bytes)?; + codecs::guard(hdr.width, hdr.height, max_pixels)?; + decode_frame( + bytes, + hdr.lzw_off, + hdr.width, + hdr.height, + hdr.interlace, + &bytes[hdr.color_table], + hdr.min_code, + hdr.transparent, + ) +} + fn decode_frame( bytes: &[u8], lzw_off: usize, diff --git a/src/runtime/image/codec_jpeg.rs b/src/runtime/image/codec_jpeg.rs index 8ddf53aee05f..98d4c92b46de 100644 --- a/src/runtime/image/codec_jpeg.rs +++ b/src/runtime/image/codec_jpeg.rs @@ -12,11 +12,11 @@ type tjhandle = *mut c_void; // TJINIT_COMPRESS=0, TJINIT_DECOMPRESS=1. unsafe extern "C" { - pub(crate) fn tj3Init(init_type: c_int) -> tjhandle; - pub(crate) fn tj3Destroy(h: tjhandle); + fn tj3Init(init_type: c_int) -> tjhandle; + fn tj3Destroy(h: tjhandle); fn tj3Set(h: tjhandle, param: c_int, value: c_int) -> c_int; - pub(crate) fn tj3Get(h: tjhandle, param: c_int) -> c_int; - pub(crate) fn tj3DecompressHeader(h: tjhandle, buf: *const u8, len: usize) -> c_int; + fn tj3Get(h: tjhandle, param: c_int) -> c_int; + fn tj3DecompressHeader(h: tjhandle, buf: *const u8, len: usize) -> c_int; fn tj3Decompress8( h: tjhandle, buf: *const u8, @@ -38,6 +38,7 @@ unsafe extern "C" { fn tj3SetScalingFactor(h: tjhandle, sf: ScalingFactor) -> c_int; fn tj3SetCroppingRegion(h: tjhandle, r: CropRegion) -> c_int; fn tj3GetScalingFactors(n: *mut c_int) -> *const ScalingFactor; + fn tj3GetErrorCode(h: tjhandle) -> c_int; pub(crate) fn tj3Free(ptr: *mut c_void); // ICC profile transport: the APP2 ICC_PROFILE marker carries the source's // colour space (sRGB implicit when absent; Display-P3 / Adobe RGB / Jpegli @@ -105,8 +106,8 @@ fn scaled(dim: u32, sf: ScalingFactor) -> u32 { // tjparam / tjpf enum values from turbojpeg.h. const TJPARAM_QUALITY: c_int = 3; const TJPARAM_SUBSAMP: c_int = 4; -pub(crate) const TJPARAM_JPEGWIDTH: c_int = 5; -pub(crate) const TJPARAM_JPEGHEIGHT: c_int = 6; +const TJPARAM_JPEGWIDTH: c_int = 5; +const TJPARAM_JPEGHEIGHT: c_int = 6; const TJPARAM_PROGRESSIVE: c_int = 12; const TJPARAM_MAXPIXELS: c_int = 24; /// `2` = save only APP2/ICC_PROFILE markers (enough for colour management, @@ -115,6 +116,33 @@ const TJPARAM_MAXPIXELS: c_int = 24; const TJPARAM_SAVEMARKERS: c_int = 25; const TJPF_RGBA: c_int = 7; const TJSAMP_420: c_int = 2; +/// `tj3GetErrorCode` after a -1 return: libjpeg warned but kept going. +const TJERR_WARNING: c_int = 0; + +/// Parse the header (SOF dims, saved markers) without touching scan data. +/// A warning (`JWRN_BOGUS_ICC`: ICC markers that do not reassemble) leaves +/// the SOF fields valid and the profile absent, so only a fatal error rejects. +pub(crate) fn read_header(h: tjhandle, bytes: &[u8]) -> Result<(u32, u32), codecs::Error> { + // SAFETY: `h` is a live tjhandle; ptr/len come from a valid `&[u8]` + // borrowed for the call. + let rc = unsafe { tj3DecompressHeader(h, bytes.as_ptr(), bytes.len()) }; + // SAFETY: `h` is live; tj3GetErrorCode only reads handle state. + if rc != 0 && unsafe { tj3GetErrorCode(h) } != TJERR_WARNING { + return Err(codecs::Error::DecodeFailed); + } + // SAFETY: `h` is live; tj3Get only reads handle state. + let rw = unsafe { tj3Get(h, TJPARAM_JPEGWIDTH) }; + // SAFETY: `h` is live; tj3Get only reads handle state. + let rh = unsafe { tj3Get(h, TJPARAM_JPEGHEIGHT) }; + // -1 on error, 0 if SOF was never reached: reject before the cast. + if rw <= 0 || rh <= 0 { + return Err(codecs::Error::DecodeFailed); + } + Ok(( + u32::try_from(rw).expect("int cast"), + u32::try_from(rh).expect("int cast"), + )) +} pub(crate) fn decode( bytes: &[u8], @@ -134,21 +162,9 @@ pub(crate) fn decode( // marker buffer is discarded if we set this after. // SAFETY: `h` is a live tjhandle for the duration of `_h_guard`. unsafe { tj3Set(h, TJPARAM_SAVEMARKERS, 2) }; - // SAFETY: `h` is live; ptr/len come from a valid `&[u8]` borrowed for the call. - if unsafe { tj3DecompressHeader(h, bytes.as_ptr(), bytes.len()) } != 0 { - return Err(codecs::Error::DecodeFailed); - } - // SAFETY: `h` is live; tj3Get only reads handle state. - let rw = unsafe { tj3Get(h, TJPARAM_JPEGWIDTH) }; - // SAFETY: `h` is live; tj3Get only reads handle state. - let rh = unsafe { tj3Get(h, TJPARAM_JPEGHEIGHT) }; - // tj3Get returns -1 on error; treat any non-positive dim as a decode - // failure rather than letting the cast trap on hostile input. - if rw <= 0 || rh <= 0 { - return Err(codecs::Error::DecodeFailed); - } - let src_w: u32 = u32::try_from(rw).expect("int cast"); - let src_h: u32 = u32::try_from(rh).expect("int cast"); + let (src_w, src_h) = read_header(h, bytes)?; + let rw = c_int::try_from(src_w).expect("int cast"); + let rh = c_int::try_from(src_h).expect("int cast"); codecs::guard(src_w, src_h, max_pixels)?; let mut w = src_w; @@ -279,7 +295,7 @@ pub(crate) fn decode( unsafe { tj3Free(p.cast()) }; } }); - if icc_ptr.is_null() { + if icc_ptr.is_null() || icc_size > codecs::MAX_ICC_PROFILE_BYTES { break 'blk None; } // SAFETY: tj3GetICCProfile wrote `icc_size` bytes at `icc_ptr`. diff --git a/src/runtime/image/codec_png.rs b/src/runtime/image/codec_png.rs index d5b88cbc9077..857ce267defd 100644 --- a/src/runtime/image/codec_png.rs +++ b/src/runtime/image/codec_png.rs @@ -44,8 +44,14 @@ unsafe extern "C" { /// the context and freed with `spng_ctx_free`; dupe out before then. fn spng_get_iccp(ctx: *mut spng_ctx, iccp: *mut Iccp) -> c_int; fn spng_set_iccp(ctx: *mut spng_ctx, iccp: *const Iccp) -> c_int; + fn spng_set_chunk_limits(ctx: *mut spng_ctx, chunk_size: usize, cache_limit: usize) -> c_int; } +/// libspng inflates iCCP/zTXt/iTXt before the first IDAT, where `max_pixels` +/// cannot see it. Over either cap the decode fails with `SPNG_ECHUNK_LIMITS`. +const MAX_CHUNK_BYTES: usize = codecs::MAX_ICC_PROFILE_BYTES; +const MAX_CHUNK_CACHE_BYTES: usize = 4 * MAX_CHUNK_BYTES; + #[repr(C)] struct Iccp { /// PNG's Latin-1 iCCP keyword (1-79 chars + NUL). libspng requires it @@ -108,6 +114,10 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result Result> = if unsafe { spng_get_iccp(ctx, &raw mut iccp) } == 0 && iccp.profile_len > 0 + && iccp.profile_len <= codecs::MAX_ICC_PROFILE_BYTES && !iccp.profile.is_null() { // SAFETY: libspng guarantees profile points to profile_len bytes owned by ctx. diff --git a/src/runtime/image/codec_webp.rs b/src/runtime/image/codec_webp.rs index 6296196d7a3a..e3804134e902 100644 --- a/src/runtime/image/codec_webp.rs +++ b/src/runtime/image/codec_webp.rs @@ -207,7 +207,8 @@ pub(crate) fn decode(bytes: &[u8], max_pixels: u64) -> Result codecs::MAX_ICC_PROFILE_BYTES { break 'blk None; } // SAFETY: chunk.bytes points into `bytes` for chunk.size bytes per libwebp contract. diff --git a/src/runtime/image/codecs.rs b/src/runtime/image/codecs.rs index a0b2693edefd..492067ad00a8 100644 --- a/src/runtime/image/codecs.rs +++ b/src/runtime/image/codecs.rs @@ -253,6 +253,10 @@ bun_core::oom_from_alloc!(Error); /// cap is ~1 GiB, which is already past where you'd want to be. pub(crate) const DEFAULT_MAX_PIXELS: u64 = 0x3FFF * 0x3FFF; +/// Largest ICC profile carried from decode to encode; bigger is "no profile". +/// Real profiles are under 4 MB, and JPEG cannot hold more than 255 × 65519. +pub(crate) const MAX_ICC_PROFILE_BYTES: usize = 8 << 20; + /// Hint from the pipeline about the eventual output size. JPEG can do M/8 /// IDCT scaling for free, so when we know the resize target up front we /// decode at the smallest factor that still ≥ the target — skipping most of @@ -357,21 +361,7 @@ pub(crate) fn probe(bytes: &[u8], max_pixels: u64) -> Result { Format::Jpeg => { // turbojpeg's header decode is already cheap (no scan data read). let handle = jpeg::Handle::init(1).ok_or(Error::OutOfMemory)?; - // SAFETY: handle is live; (ptr,len) come from a valid live slice. - if unsafe { jpeg::tj3DecompressHeader(handle.as_ptr(), bytes.as_ptr(), bytes.len()) } - != 0 - { - return Err(Error::DecodeFailed); - } - // SAFETY: handle is live and has had a header decoded into it above. - let rw = unsafe { jpeg::tj3Get(handle.as_ptr(), jpeg::TJPARAM_JPEGWIDTH) }; - // SAFETY: same handle invariant as above. - let rh = unsafe { jpeg::tj3Get(handle.as_ptr(), jpeg::TJPARAM_JPEGHEIGHT) }; - if rw <= 0 || rh <= 0 { - return Err(Error::DecodeFailed); - } - w = u32::try_from(rw).expect("int cast"); - h = u32::try_from(rh).expect("int cast"); + (w, h) = jpeg::read_header(handle.as_ptr(), bytes)?; } Format::Webp => { let mut cw: c_int = 0; @@ -393,14 +383,10 @@ pub(crate) fn probe(bytes: &[u8], max_pixels: u64) -> Result { h = ih.height; } Format::Gif => { - // sig(6) · LSD: w(u16le) h(u16le) … - if bytes.len() < 10 { - return Err(Error::DecodeFailed); - } - w = u16::from_le_bytes(bytes[6..8].try_into().expect("infallible: size matches")) - as u32; - h = u16::from_le_bytes(bytes[8..10].try_into().expect("infallible: size matches")) - as u32; + // The dims the decoder produces, not the Logical Screen Descriptor's. + let hdr = gif::parse_header(bytes)?; + w = hdr.width; + h = hdr.height; } Format::Tiff => { // IFD walk would be a full TIFF parser; defer to whoever @@ -541,7 +527,10 @@ pub(crate) fn encode( ) -> Result { // SAFETY: `EncodeOptions.icc_profile` is borrowed from the caller for the // duration of this call (raw-ptr stand-in for a lifetime param). - let icc: Option<&[u8]> = opts.icc_profile.map(|p| unsafe { p.as_ref() }); + let icc: Option<&[u8]> = opts + .icc_profile + .map(|p| unsafe { p.as_ref() }) + .filter(|p| p.len() <= MAX_ICC_PROFILE_BYTES); match opts.format { Format::Jpeg => jpeg::encode(rgba, width, height, opts.quality, opts.progressive, icc), // PNG carries iCCP on both truecolour and indexed images — quantise diff --git a/test/js/bun/image/image-adversarial.test.ts b/test/js/bun/image/image-adversarial.test.ts index 220e7df1beaf..cde765aabf31 100644 --- a/test/js/bun/image/image-adversarial.test.ts +++ b/test/js/bun/image/image-adversarial.test.ts @@ -778,3 +778,212 @@ describe("random-byte fuzz", () => { }); } }); + +// ─── 11. path strings ──────────────────────────────────────────────────────── + +describe("path source", () => { + test("interior NUL is rejected at construction with ERR_INVALID_ARG_VALUE, like Bun.file()", async () => { + // The worker opens the path as a C string, so "secret-no-ext\0.png" would + // open "secret-no-ext" while `.endsWith(".png")` on the JS string says + // yes. The constructor must refuse it the way Bun.file() and node:fs do. + using dir = tempDir("image-nul-path", { "secret-no-ext": tinyPng }); + const p = join(String(dir), "secret-no-ext\0.png"); + expect(p.endsWith(".png")).toBe(true); + const codeOf = (f: () => unknown) => { + try { + f(); + } catch (e: any) { + return e.code; + } + return "no throw"; + }; + expect(codeOf(() => new Bun.Image(p))).toBe("ERR_INVALID_ARG_VALUE"); + expect(codeOf(() => Bun.file(p))).toBe("ERR_INVALID_ARG_VALUE"); + // Sanity: the same path without the NUL decodes. + expect(await new Bun.Image(join(String(dir), "secret-no-ext")).metadata()).toEqual({ + width: 2, + height: 2, + format: "png", + }); + }); +}); + +// ─── 12. ancillary-chunk bombs and ICC profile transport ───────────────────── + +describe("PNG ancillary chunk limits", () => { + // A 4×4 PNG whose `kind` chunk inflates to `inflatedBytes` of zeros. The + // file itself stays tiny (deflate of zeros is ~1000:1), so `maxPixels` + // never sees anything wrong: the bomb is in metadata libspng inflates + // before the first IDAT. + function pngWithInflatingChunk(kind: "iCCP" | "zTXt" | "iTXt", inflatedBytes: number): Uint8Array { + const ihdr = new Uint8Array(13); + const iv = new DataView(ihdr.buffer); + iv.setUint32(0, 4); + iv.setUint32(4, 4); + ihdr[8] = 8; + ihdr[9] = 6; + const deflated = zlib.deflateSync(Buffer.alloc(inflatedBytes)); + // iCCP: keyword \0 method deflate · zTXt: keyword \0 method deflate · + // iTXt: keyword \0 compressed=1 method=0 lang \0 translated \0 deflate + const head = + kind === "iTXt" ? Buffer.from("k\0\x01\0\0\0", "latin1") : Buffer.from(kind === "iCCP" ? "icc\0\0" : "k\0\0"); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", ihdr), + pngChunk(kind, Buffer.concat([head, deflated])), + pngChunk("IDAT", zlib.deflateSync(Buffer.alloc(4 * (1 + 4 * 4)))), + pngChunk("IEND", new Uint8Array(0)), + ]); + } + + test.each(["iCCP", "zTXt", "iTXt"] as const)("%s inflating to 32 MiB rejects instead of inflating", async kind => { + const png = pngWithInflatingChunk(kind, 32 << 20); + expect(png.length).toBeLessThan(100_000); + // metadata() reads only the IHDR, so it is unaffected. + expect(await new Bun.Image(png).metadata()).toEqual({ width: 4, height: 4, format: "png" }); + // libspng stops inflating at the 8 MiB per-chunk cap and fails the + // decode; the 32 MiB never materialises and `maxPixels` is irrelevant. + await expect(new Bun.Image(png, { maxPixels: 64 }).png().bytes()).rejects.toThrow(/decode failed/); + }); + + test("an oversized iCCP does not ride into the output", async () => { + // Pre-fix a 1 MB PNG became a 1 GiB WebP (the inflated profile was + // copied into an ICCP chunk verbatim). Anything over the cap fails the + // decode, so no terminal can emit it. + const png = pngWithInflatingChunk("iCCP", 9 << 20); + await expect(new Bun.Image(png).webp().bytes()).rejects.toThrow(/decode failed/); + await expect(new Bun.Image(png).jpeg().bytes()).rejects.toThrow(/decode failed/); + }); + + test("a 1 MiB iCCP is under the cap and still round-trips", async () => { + // Real profiles are far smaller than this (sRGB ~3 KB, the largest + // CMYK press profiles ~3 MB), so the cap must not touch them. + const profile = new Uint8Array(1 << 20); + for (let i = 0; i < profile.length; i++) profile[i] = (i * 131 + 7) & 0xff; + const body = Buffer.concat([Buffer.from("ICC Profile\0\0", "latin1"), zlib.deflateSync(profile)]); + const png = Buffer.concat([tinyPng.subarray(0, 33), pngChunk("iCCP", body), tinyPng.subarray(33)]); + const out = await new Bun.Image(png).png().bytes(); + const dv = new DataView(out.buffer, out.byteOffset, out.byteLength); + let off = 8; + let got: Uint8Array | null = null; + while (off + 8 <= out.length) { + const len = dv.getUint32(off); + const type = String.fromCharCode(out[off + 4], out[off + 5], out[off + 6], out[off + 7]); + if (type === "iCCP") { + const chunk = out.subarray(off + 8, off + 8 + len); + let nul = 0; + while (chunk[nul] !== 0) nul++; + got = zlib.inflateSync(chunk.subarray(nul + 2)); + break; + } + off += 12 + len; + } + expect(got).not.toBeNull(); + expect(Buffer.compare(got!, profile)).toBe(0); + }); +}); + +describe("JPEG ICC markers", () => { + // One APP2 `ICC_PROFILE` segment with the given sequence number / count + // and a 16-byte payload. + function app2(seq: number, count: number): Buffer { + const body = Buffer.concat([ + Buffer.from("ICC_PROFILE\0", "latin1"), + Buffer.from([seq, count]), + Buffer.alloc(16, 0xaa), + ]); + const seg = Buffer.alloc(4 + body.length); + seg[0] = 0xff; + seg[1] = 0xe2; + seg.writeUInt16BE(body.length + 2, 2); + body.copy(seg, 4); + return seg; + } + // Splice segments right after SOI of a known-good JPEG. + const withApp2 = (...segs: Buffer[]) => Buffer.concat([tinyJpeg.subarray(0, 2), ...segs, tinyJpeg.subarray(2)]); + const hasIccMarker = (jpg: Uint8Array) => + Buffer.from(jpg.buffer, jpg.byteOffset, jpg.byteLength).includes(Buffer.from("ICC_PROFILE\0", "latin1")); + + test.each([ + ["sequence number above the count", withApp2(app2(2, 1))], + ["duplicate sequence number", withApp2(app2(1, 2), app2(1, 2))], + ["inconsistent counts", withApp2(app2(1, 2), app2(2, 3))], + ["missing sequence number", withApp2(app2(1, 3), app2(3, 3))], + ])("%s: pixels decode, the profile is dropped", async (_name, jpg) => { + // libjpeg reports an ICC sequence it cannot reassemble as a warning + // (JWRN_BOGUS_ICC). The scan data is intact, so the image must decode + // without a profile (what browsers and libvips do), not fail outright. + expect(await new Bun.Image(jpg).metadata()).toEqual({ width: 2, height: 2, format: "jpeg" }); + const out = await new Bun.Image(jpg).jpeg().bytes(); + expect(hasIccMarker(out)).toBe(false); + expect(Buffer.compare(await rgbaOf(jpg), await rgbaOf(tinyJpeg))).toBe(0); + }); + + test("a profile too large for JPEG's 255-marker ceiling is dropped, so .jpeg() output re-decodes", async () => { + // The APP2 sequence counter is one byte: a profile over 255 × 65519 B + // gets wrapped marker numbers and libjpeg-turbo itself then rejects the + // file it wrote. RIFF stores chunks raw, so a WebP can carry such a + // profile; wrap tinyWebp's VP8 bitstream in VP8X + a 17 MiB ICCP chunk. + const profile = Buffer.alloc(17 << 20, 0x5a); + const vp8x = Buffer.from([0x20, 0, 0, 0, 1, 0, 0, 1, 0, 0]); // ICC flag · canvas 2×2 (minus one, 24-bit LE) + const riffChunk = (fourcc: string, payload: Uint8Array) => { + const size = Buffer.alloc(4); + size.writeUInt32LE(payload.length); + return Buffer.concat([ + Buffer.from(fourcc), + size, + payload, + payload.length & 1 ? Buffer.alloc(1) : Buffer.alloc(0), + ]); + }; + const body = Buffer.concat([ + Buffer.from("WEBP"), + riffChunk("VP8X", vp8x), + riffChunk("ICCP", profile), + tinyWebp.subarray(12), + ]); + const riffSize = Buffer.alloc(4); + riffSize.writeUInt32LE(body.length); + const webp = Buffer.concat([Buffer.from("RIFF"), riffSize, body]); + expect(await new Bun.Image(webp).metadata()).toEqual({ width: 2, height: 2, format: "webp" }); + + const jpg = await new Bun.Image(webp).jpeg().bytes(); + expect(hasIccMarker(jpg)).toBe(false); + expect(jpg.length).toBeLessThan(1 << 20); + expect(await new Bun.Image(jpg).metadata()).toEqual({ width: 2, height: 2, format: "jpeg" }); + // Same cap on the WebP → WebP and WebP → PNG paths. + const rewebp = await new Bun.Image(webp).webp().bytes(); + expect(rewebp.length).toBeLessThan(1 << 20); + expect((await new Bun.Image(webp).png().bytes()).length).toBeLessThan(1 << 20); + }); +}); + +// ─── 13. GIF: metadata() and decode must size the same frame ───────────────── + +describe("GIF probe", () => { + // Logical screen 1×1, first Image Descriptor 16383×16383, LZW = clear+EOI. + // The static decoder sizes its output (and runs the maxPixels guard) from + // the Image Descriptor, so metadata() has to report the same dims or a + // "check metadata() first" gate lets a 1 GiB canvas through. + const g = Buffer.from("47494638396101000100800000000000255b0d2c00000000ff3fff3f0002022c003b", "hex"); + // Reverse: screen 65535×65535 wrapping a 4×4 frame. + const r = Buffer.from(g); + r.writeUInt16LE(65535, 6); + r.writeUInt16LE(65535, 8); + r.writeUInt16LE(4, 24); + r.writeUInt16LE(4, 26); + + test("metadata() reports the first frame's dimensions, not the logical screen", async () => { + expect(await new Bun.Image(g).metadata()).toEqual({ width: 16383, height: 16383, format: "gif" }); + expect(await new Bun.Image(r).metadata()).toEqual({ width: 4, height: 4, format: "gif" }); + }); + + test("metadata() and decode agree on maxPixels", async () => { + Bun.Image.backend = "bun"; + await expect(new Bun.Image(g, { maxPixels: 64 }).metadata()).rejects.toThrow(/maxPixels/); + await expect(new Bun.Image(g, { maxPixels: 64 }).png().bytes()).rejects.toThrow(/maxPixels/); + expect(await new Bun.Image(r, { maxPixels: 64 }).metadata()).toEqual({ width: 4, height: 4, format: "gif" }); + const px = await rgbaOf(r); + expect(px.length).toBe(4 * 4 * 4); + }); +});