Files
Codeman/test/heic-jpeg-core.test.ts
T
Codeman maintainer 309959be27 fix(review): worker-thread HEIC conversion with bomb guard, concurrency cap, and magic-byte routing (PR #151)
- Event-loop blockage: HEIC decode/encode (CPU-synchronous libheif WASM +
  jpeg-js) now runs in a per-conversion worker_threads Worker
  (src/web/heic-jpeg-worker.ts, spawned by heic-jpeg-converter.ts) with
  resourceLimits and a 30s hard timeout that terminates the worker —
  verified end-to-end under tsx and against compiled dist/ output with a
  real iPhone HEIC (event-loop max stall 52ms during conversion).
- No server-side concurrency cap: conversions now acquire a slot from the
  existing global runWithConversionLimit() pool (document-conversion-limiter),
  bounding peak decode memory/CPU across simultaneous uploads.
- Decompression bomb: header-declared dimensions are read via heic-decode's
  allocation-free `.all` path and rejected above 64MP BEFORE decode() can
  allocate width*height*4 bytes (a <300-byte crafted file can declare
  30000x30000 = 3.6GB). Regression-tested with a crafted ISOBMFF fixture
  against the real heic-decode WASM (test/heic-jpeg-core.test.ts).
- Mislabeled HEIC (documented Android/MIUI case): conversion now routes on
  ftyp magic-byte sniff of the raw buffer regardless of declared
  ext/Content-Type, so a HEIF uploaded as image/jpeg converts instead of
  415ing; the magic-mismatch 415 only fires for genuinely unrecognized bytes.
- Brand allowlist narrowed to what heic-decode's isHeic() accepts
  (heim/heis/hevm/hevs dropped — they could only ever fail conversion).
- Converted-output size: the JPEG result is checked against
  MAX_PASTE_IMAGE_BYTES (jpeg-js can inflate a within-limit HEIC past the cap).
- Deps: heic-convert replaced with its underlying heic-decode + jpeg-js
  (the wrapper could not expose the pre-decode dimension check); lockfile
  synced, drops pngjs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 18:06:59 +02:00

126 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Tests for the HEIC → JPEG conversion core (heic-jpeg-worker.ts).
*
* Exercises the REAL heic-decode WASM parse path (no mocks) for the
* decompression-bomb guard: a crafted <300-byte HEIC can declare arbitrary
* dimensions in its `ispe` box, and heic-decode's plain decode path allocates
* `width * height * 4` bytes straight from those header values (30000×30000 →
* a 3.6GB allocation). The guard must reject via the allocation-free `.all`
* dimension read BEFORE decode().
*
* Port: N/A (pure module test, no server).
*/
import { describe, it, expect, vi } from 'vitest';
import { convertHeicBufferToJpeg, MAX_HEIC_DECODE_PIXELS } from '../src/web/heic-jpeg-worker.js';
// ── Minimal ISOBMFF/HEIF builder — just enough boxes (ftyp/meta/hdlr/pitm/
// iloc/iinf/iprp[hvcC+ispe]/mdat) for libheif to parse the image handle and
// report the ispe-declared dimensions. There is no real HEVC bitstream, so
// pixel decode of these files always fails — which is the point: the guard
// must fire before any decode is attempted.
function box(type: string, ...payloads: (Buffer | string)[]): Buffer {
const payload = Buffer.concat(payloads.map((p) => (Buffer.isBuffer(p) ? p : Buffer.from(p))));
const header = Buffer.alloc(8);
header.writeUInt32BE(8 + payload.length, 0);
header.write(type, 4, 'ascii');
return Buffer.concat([header, payload]);
}
function fullbox(type: string, version: number, flags: number, ...payloads: (Buffer | string)[]): Buffer {
const vf = Buffer.alloc(4);
vf.writeUInt32BE((version << 24) | flags, 0);
return box(type, vf, ...payloads);
}
function u16(n: number): Buffer {
const b = Buffer.alloc(2);
b.writeUInt16BE(n, 0);
return b;
}
function u32(n: number): Buffer {
const b = Buffer.alloc(4);
b.writeUInt32BE(n, 0);
return b;
}
/** Craft a HEIC container whose header declares `width`×`height`. */
function craftHeic(width: number, height: number): Buffer {
const ftyp = box('ftyp', 'heic', u32(0), 'mif1heic');
const hdlr = fullbox('hdlr', 0, 0, u32(0), 'pict', u32(0), u32(0), u32(0), Buffer.from([0]));
const pitm = fullbox('pitm', 0, 0, u16(1));
const infe = fullbox('infe', 2, 0, u16(1), u16(0), 'hvc1', Buffer.from([0]));
const iinf = fullbox('iinf', 0, 0, u16(1), infe);
const ispe = fullbox('ispe', 0, 0, u32(width), u32(height));
// Minimal HEVCDecoderConfigurationRecord (23 bytes, zero parameter-set arrays).
const hvcC = box(
'hvcC',
Buffer.from([
0x01, 0x01, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5d, 0xf0, 0x00, 0xfc, 0xfd, 0xf8, 0xf8, 0x00,
0x00, 0x03, 0x00,
]),
Buffer.from([0x00])
);
const ipco = box('ipco', hvcC, ispe);
const ipma = fullbox('ipma', 0, 0, u32(1), u16(1), Buffer.from([2]), Buffer.from([0x81, 0x02]));
const iprp = box('iprp', ipco, ipma);
// iloc v0: offset_size=4, length_size=4, base_offset_size=0; one extent in mdat.
const ilocItem = Buffer.concat([u16(1), u16(0), u16(1), u32(0), u32(16)]);
const iloc = fullbox('iloc', 0, 0, Buffer.from([0x44, 0x00]), u16(1), ilocItem);
const meta = fullbox('meta', 0, 0, hdlr, pitm, iloc, iinf, iprp);
const mdat = box('mdat', Buffer.alloc(16));
return Buffer.concat([ftyp, meta, mdat]);
}
describe('heic-jpeg-core', () => {
it('rejects a crafted bomb header (30000×30000 declared, 3.6GB decode) before decoding', async () => {
const bomb = craftHeic(30000, 30000);
expect(bomb.length).toBeLessThan(1024); // tiny input, huge declared output
await expect(convertHeicBufferToJpeg(bomb)).rejects.toThrow(/30000x30000 exceed the 64MP decode limit/);
});
it('rejects dimensions just over the cap', async () => {
// 8000×8001 = 64,008,000 px — barely over MAX_HEIC_DECODE_PIXELS (64MP).
expect(8000 * 8001).toBeGreaterThan(MAX_HEIC_DECODE_PIXELS);
await expect(convertHeicBufferToJpeg(craftHeic(8000, 8001))).rejects.toThrow(/decode limit/);
});
it('lets dimensions under the cap through the guard (failure, if any, comes from pixel decode)', async () => {
// The crafted file has no real HEVC bitstream, so decode fails — but NOT
// with the dimension-limit error, proving the guard ran and passed.
await expect(convertHeicBufferToJpeg(craftHeic(100, 100))).rejects.toThrow(/^(?!.*decode limit).*$/);
});
it('rejects non-HEIC bytes', async () => {
await expect(convertHeicBufferToJpeg(Buffer.from('this is definitely not a HEIC image'))).rejects.toThrow(
/not a HEIC image/i
);
});
it('encodes decoded RGBA into JPEG bytes with valid magic (heic-decode mocked, real jpeg-js)', async () => {
const dispose = vi.fn();
const handle = {
width: 2,
height: 2,
decode: async () => ({ width: 2, height: 2, data: new Uint8ClampedArray(16).fill(128) }),
};
vi.doMock('heic-decode', () => {
const decode = Object.assign(async () => handle.decode(), {
all: async () => Object.assign([handle], { dispose }),
});
return { default: decode };
});
try {
const jpeg = await convertHeicBufferToJpeg(Buffer.from('mock input'));
expect(jpeg[0]).toBe(0xff);
expect(jpeg[1]).toBe(0xd8);
expect(jpeg[2]).toBe(0xff);
expect(dispose).toHaveBeenCalledTimes(1);
} finally {
vi.doUnmock('heic-decode');
}
});
});