mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
- 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>
126 lines
5.2 KiB
TypeScript
126 lines
5.2 KiB
TypeScript
/**
|
||
* @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');
|
||
}
|
||
});
|
||
});
|