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>
This commit is contained in:
Codeman maintainer
2026-07-12 18:06:59 +02:00
parent bb1d16e230
commit 309959be27
9 changed files with 428 additions and 68 deletions
+2 -24
View File
@@ -28,7 +28,8 @@
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"commander": "^12.1.0", "commander": "^12.1.0",
"fastify": "^5.8.5", "fastify": "^5.8.5",
"heic-convert": "^2.1.0", "heic-decode": "^2.1.0",
"jpeg-js": "^0.4.4",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^14.0.0", "uuid": "^14.0.0",
@@ -7024,29 +7025,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/heic-convert": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-convert/-/heic-convert-2.1.0.tgz",
"integrity": "sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==",
"license": "ISC",
"dependencies": {
"heic-decode": "^2.0.0",
"jpeg-js": "^0.4.4",
"pngjs": "^6.0.0"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/heic-convert/node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/heic-decode": { "node_modules/heic-decode": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz", "resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
+2 -1
View File
@@ -69,7 +69,8 @@
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"commander": "^12.1.0", "commander": "^12.1.0",
"fastify": "^5.8.5", "fastify": "^5.8.5",
"heic-convert": "^2.1.0", "heic-decode": "^2.1.0",
"jpeg-js": "^0.4.4",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^14.0.0", "uuid": "^14.0.0",
-9
View File
@@ -1,9 +0,0 @@
declare module 'heic-convert' {
export interface ConvertOptions {
buffer: Buffer;
format: 'JPEG' | 'PNG';
quality?: number;
}
export default function convert(options: ConvertOptions): Promise<Buffer | Uint8Array | ArrayBuffer>;
}
+24
View File
@@ -0,0 +1,24 @@
declare module 'heic-decode' {
export interface DecodedHeicImage {
width: number;
height: number;
data: Uint8ClampedArray;
}
/** Handle exposing header-declared dimensions WITHOUT decoding pixels. */
export interface HeicImageHandle {
width: number;
height: number;
decode(): Promise<DecodedHeicImage>;
}
export type HeicImageHandles = HeicImageHandle[] & { dispose(): void };
interface HeicDecode {
(input: { buffer: Buffer | Uint8Array }): Promise<DecodedHeicImage>;
all(input: { buffer: Buffer | Uint8Array }): Promise<HeicImageHandles>;
}
const decode: HeicDecode;
export default decode;
}
+82
View File
@@ -0,0 +1,82 @@
/**
* @fileoverview Main-thread wrapper for HEIC/HEIF → JPEG conversion.
*
* The actual decode/encode (`heic-jpeg-worker.ts`) is CPU-synchronous WASM + JS,
* so it runs in a dedicated `worker_threads` Worker per conversion — never on
* the event loop that serves every session's SSE/PTY/WS traffic. On top of
* the worker isolation this wrapper enforces:
* - the global converter concurrency cap (`runWithConversionLimit`, shared
* with the pdftoppm/soffice document converters) so N simultaneous uploads
* can't pin N cores / N × 256MB decode buffers at once;
* - a hard timeout that terminates the worker (a wedged WASM decode can't be
* cancelled cooperatively);
* - the paste-image size cap on the *output* — jpeg-js is a far less
* efficient encoder than HEVC, so a within-limit HEIC can inflate past
* MAX_PASTE_IMAGE_BYTES.
*/
import { Worker } from 'node:worker_threads';
import { runWithConversionLimit } from '../document-conversion-limiter.js';
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { HEIC_JPEG_QUALITY, type HeicWorkerInput, type HeicWorkerResult } from './heic-jpeg-worker.js';
/** Hard cap on a single conversion; the worker is terminated when it fires. */
export const HEIC_CONVERSION_TIMEOUT_MS = 30_000;
// V8-heap guardrails for the conversion worker — defense in depth only: large
// TypedArray/WASM backing stores are external to the V8 heap, so the real
// memory bound is the 64MP dimension pre-check in heic-jpeg-worker.ts.
const WORKER_RESOURCE_LIMITS = { maxOldGenerationSizeMb: 1024, maxYoungGenerationSizeMb: 128, stackSizeMb: 8 };
function workerUrl(): URL {
// Compiled installs run the tsc-emitted .js sibling in dist/; dev under tsx
// runs the .ts source directly (tsx's loader propagates to worker threads).
const file = import.meta.url.endsWith('.ts') ? './heic-jpeg-worker.ts' : './heic-jpeg-worker.js';
return new URL(file, import.meta.url);
}
/**
* Convert HEIC/HEIF bytes to JPEG bytes off-thread. Rejects on invalid input,
* over-limit dimensions, oversized output, timeout, or worker failure.
*/
export async function convertHeicToJpeg(imageBytes: Buffer): Promise<Buffer> {
return runWithConversionLimit(
() =>
new Promise<Buffer>((resolve, reject) => {
const worker = new Worker(workerUrl(), {
workerData: { heicInput: imageBytes, quality: HEIC_JPEG_QUALITY } satisfies HeicWorkerInput,
resourceLimits: WORKER_RESOURCE_LIMITS,
});
let settled = false;
const settle = (fn: () => void): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
void worker.terminate();
};
const timer = setTimeout(() => {
settle(() => reject(new Error(`HEIC conversion timed out after ${HEIC_CONVERSION_TIMEOUT_MS}ms`)));
}, HEIC_CONVERSION_TIMEOUT_MS);
worker.on('message', (msg: HeicWorkerResult) => {
settle(() => {
if (!msg.ok) {
reject(new Error(msg.error));
return;
}
const out = Buffer.from(msg.data.buffer, msg.data.byteOffset, msg.data.byteLength);
if (out.length > MAX_PASTE_IMAGE_BYTES) {
const maxMb = Math.round(MAX_PASTE_IMAGE_BYTES / (1024 * 1024));
reject(new Error(`converted JPEG (${out.length} bytes) exceeds the ${maxMb}MB upload limit`));
return;
}
resolve(out);
});
});
worker.on('error', (err) => settle(() => reject(err)));
worker.on('exit', (code) => {
settle(() => reject(new Error(`HEIC conversion worker exited unexpectedly (code ${code})`)));
});
})
);
}
+93
View File
@@ -0,0 +1,93 @@
/**
* @fileoverview HEIC/HEIF → JPEG conversion core + worker-thread entry.
*
* Spawned per conversion by `heic-jpeg-converter.ts` so the CPU-synchronous
* libheif WASM decode + jpeg-js encode never run on the server's main thread
* (on the event loop they would freeze every session's SSE/PTY/WS handling
* for seconds per photo). Input arrives via `workerData`; the result (or
* error message) is posted back as a single message and the thread exits.
*
* The conversion logic lives in this same file (exported, guarded bootstrap)
* rather than a sibling module: the worker runs from `.ts` source under tsx
* in dev, where relative `.js` imports don't resolve inside worker threads —
* only `node:` builtins are imported at top level. Unit tests import
* `convertHeicBufferToJpeg` directly; the bootstrap only runs when spawned
* with our `workerData` shape.
*
* Decompression-bomb guard: heic-decode's `.all` path exposes the
* header-declared {width, height} per image WITHOUT decoding pixels, while
* its plain decode path allocates `width * height * 4` bytes straight from
* those header values — a <1KB crafted file declaring 30000×30000 would
* demand a 3.6GB allocation. We reject anything above MAX_HEIC_DECODE_PIXELS
* before calling `decode()`.
*/
import { parentPort, workerData } from 'node:worker_threads';
/** Max header-declared pixel count we will decode (64MP ≈ 256MB RGBA). */
export const MAX_HEIC_DECODE_PIXELS = 64_000_000;
/** JPEG quality used for converted HEIC uploads (matches heic-convert's default). */
export const HEIC_JPEG_QUALITY = 0.92;
export interface HeicWorkerInput {
heicInput: Uint8Array;
quality: number;
}
export type HeicWorkerResult = { ok: true; data: Uint8Array } | { ok: false; error: string };
/**
* Convert HEIC/HEIF bytes to JPEG bytes. Throws on non-HEIC input, empty
* containers, over-limit dimensions, and non-JPEG encoder output.
*/
export async function convertHeicBufferToJpeg(input: Uint8Array, quality: number = HEIC_JPEG_QUALITY): Promise<Buffer> {
const { default: decode } = await import('heic-decode');
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input.buffer, input.byteOffset, input.byteLength);
const images = await decode.all({ buffer });
try {
if (images.length === 0) throw new Error('no image found in HEIC container');
const { width, height } = images[0];
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width <= 0 ||
height <= 0 ||
width * height > MAX_HEIC_DECODE_PIXELS
) {
throw new Error(
`HEIC dimensions ${width}x${height} exceed the ${Math.floor(MAX_HEIC_DECODE_PIXELS / 1_000_000)}MP decode limit`
);
}
const decoded = await images[0].decode();
const { encode } = await import('jpeg-js');
// Same output path as heic-convert's JPEG format (jpeg-js at quality*100).
const jpeg = encode(
{ data: decoded.data, width: decoded.width, height: decoded.height },
Math.floor(quality * 100)
).data;
const jpegBytes = Buffer.isBuffer(jpeg) ? jpeg : Buffer.from(jpeg);
if (jpegBytes.length < 3 || jpegBytes[0] !== 0xff || jpegBytes[1] !== 0xd8 || jpegBytes[2] !== 0xff) {
throw new Error('HEIC conversion did not produce JPEG bytes');
}
return jpegBytes;
} finally {
images.dispose();
}
}
// ── Worker bootstrap ──────────────────────────────────────────────────────
// Runs only when spawned by heic-jpeg-converter.ts: requires a parent port
// AND our exact workerData shape, so importing this module from the main
// thread (or a test runner's own worker pool) stays inert.
const request = workerData as HeicWorkerInput | null | undefined;
if (parentPort && request && request.heicInput instanceof Uint8Array && typeof request.quality === 'number') {
const port = parentPort;
try {
const jpegBytes = await convertHeicBufferToJpeg(request.heicInput, request.quality);
port.postMessage({ ok: true, data: jpegBytes } satisfies HeicWorkerResult);
} catch (err: unknown) {
const error = err instanceof Error ? err.message : String(err);
port.postMessage({ ok: false, error } satisfies HeicWorkerResult);
}
}
+21 -32
View File
@@ -54,6 +54,7 @@ import {
} from '../../hooks-config.js'; } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js'; import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js'; import { imageWatcher } from '../../image-watcher.js';
import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js'; import { getLifecycleLog } from '../../session-lifecycle-log.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js'; import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
@@ -191,30 +192,18 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
return data[0] === 0x42 && data[1] === 0x4d; return data[0] === 0x42 && data[1] === 0x4d;
case '.heic': case '.heic':
case '.heif': { case '.heif': {
// ISO Base Media File Format: size + "ftyp" + major brand. // ISO Base Media File Format: size + "ftyp" + major brand. The brand
// list matches heic-decode's own isHeic() — accepting more brands here
// would only route bytes into a conversion that always throws.
if (u32be(4) !== 0x66747970) return false; if (u32be(4) !== 0x66747970) return false;
const brand = data.subarray(8, 12).toString('ascii'); const brand = data.subarray(8, 12).toString('ascii');
return ['heic', 'heix', 'hevc', 'hevx', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1'].includes(brand); return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
} }
default: default:
return false; return false;
} }
} }
async function convertHeicToJpeg(imageBytes: Buffer): Promise<Buffer> {
const { default: convert } = await import('heic-convert');
const converted = await convert({ buffer: imageBytes, format: 'JPEG', quality: 0.92 });
const jpegBytes = Buffer.isBuffer(converted)
? converted
: converted instanceof ArrayBuffer
? Buffer.from(converted)
: Buffer.from(converted.buffer, converted.byteOffset, converted.byteLength);
if (!imageMagicMatchesExt(jpegBytes, '.jpg')) {
throw new Error('HEIC conversion did not produce JPEG bytes');
}
return jpegBytes;
}
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute. // Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
// Bucket map entries are pruned when they drift > 1h stale to bound memory // Bucket map entries are pruned when they drift > 1h stale to bound memory
// against a flood of unique IP keys. // against a flood of unique IP keys.
@@ -1889,22 +1878,12 @@ export function registerSessionRoutes(
); );
} }
// Sniff actual bytes — filename and Content-Type are both attacker-supplied. // Route HEIC on the raw bytes, NOT the declared ext/mime: on some Android
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME. // galleries (e.g. MIUI) a HEIF comes back mislabeled as image/jpeg, and
if (!imageMagicMatchesExt(imageBytes, ext)) { // browsers that cannot decode HEIF upload the original file as-is — so a
// Diagnostic: on some Android galleries (e.g. MIUI) a WebP/HEIF is // HEIC payload can arrive under any declared type. Filename and
// mislabeled as image/jpeg, so the declared ext passes the allowlist but // Content-Type are attacker-supplied anyway; only the bytes are trusted.
// the magic bytes do not. Log the real header so format mismatches can be if (imageMagicMatchesExt(imageBytes, '.heic')) {
// pinned down without a reproduce-and-guess loop. The client now
// re-encodes images to JPEG/PNG before upload, so this should be rare.
console.warn(
`[paste-image] magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${imageBytes.subarray(0, 12).toString('hex')}`
);
reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
if (ext === '.heic' || ext === '.heif') {
try { try {
imageBytes = await convertHeicToJpeg(imageBytes); imageBytes = await convertHeicToJpeg(imageBytes);
ext = '.jpg'; ext = '.jpg';
@@ -1915,6 +1894,16 @@ export function registerSessionRoutes(
reply.code(415); reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG'); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG');
} }
} else if (!imageMagicMatchesExt(imageBytes, ext)) {
// Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
// serve back with image/png MIME. Log the real header so format
// mismatches can be pinned down without a reproduce-and-guess loop. The
// client re-encodes images to JPEG/PNG before upload, so this is rare.
console.warn(
`[paste-image] magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${imageBytes.subarray(0, 12).toString('hex')}`
);
reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
} }
// Save to {workingDir}/.claude-images/ // Save to {workingDir}/.claude-images/
+125
View File
@@ -0,0 +1,125 @@
/**
* @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');
}
});
});
+79 -2
View File
@@ -26,12 +26,15 @@ import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real). // Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() })); const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
// The real converter spawns a worker thread (TS worker file — not loadable
// under vitest); the conversion pipeline itself is covered by
// test/heic-jpeg-core.test.ts against the real heic-decode WASM.
const heicConvert = vi.hoisted(() => vi.fn(async () => Buffer.from('ffd8ffe000104a4649460001', 'hex'))); const heicConvert = vi.hoisted(() => vi.fn(async () => Buffer.from('ffd8ffe000104a4649460001', 'hex')));
vi.mock('node:child_process', async (orig) => { vi.mock('node:child_process', async (orig) => {
const actual = await orig<typeof import('node:child_process')>(); const actual = await orig<typeof import('node:child_process')>();
return { ...actual, execFile }; return { ...actual, execFile };
}); });
vi.mock('heic-convert', () => ({ default: heicConvert })); vi.mock('../../src/web/heic-jpeg-converter.js', () => ({ convertHeicToJpeg: heicConvert }));
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
@@ -170,7 +173,81 @@ describe('session-routes', () => {
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.success).toBe(true); expect(body.success).toBe(true);
expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/); expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/);
expect(heicConvert).toHaveBeenCalledWith({ buffer: heic, format: 'JPEG', quality: 0.92 }); expect(heicConvert).toHaveBeenCalledWith(heic);
});
it('converts mislabeled HEIC (declared image/jpeg, HEIF bytes — the MIUI/Android case) via magic sniff', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-heic-mislabel-'));
harness.ctx._session.workingDir = workDir;
heicConvert.mockClear();
const boundary = 'codeman-test-boundary';
// ftyp brand mif1 — HEIF bytes hiding under a JPEG filename + MIME.
const heic = Buffer.from('000000346674797061696631000000006d69663168656963', 'hex');
heic.write('mif1', 8, 'ascii'); // major brand
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
headers: {
host: 'codeman.test',
origin: 'http://codeman.test',
'content-type': `multipart/form-data; boundary=${boundary}`,
},
payload: imageUploadBody(boundary, 'IMG_2001.jpg', 'image/jpeg', heic),
});
await rm(workDir, { recursive: true });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(body.data.path).toMatch(/\/\.claude-images\/paste-\d+-[a-f0-9]{8}\.jpg$/);
expect(heicConvert).toHaveBeenCalledWith(heic);
});
it('returns 415 with the error envelope when HEIC conversion fails', async () => {
heicConvert.mockClear();
heicConvert.mockRejectedValueOnce(new Error('HEIC dimensions 30000x30000 exceed the 64MP decode limit'));
const boundary = 'codeman-test-boundary';
const heic = Buffer.from('00000034667479706865696300000000', 'hex');
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
headers: {
host: 'codeman.test',
origin: 'http://codeman.test',
'content-type': `multipart/form-data; boundary=${boundary}`,
},
payload: imageUploadBody(boundary, 'IMG_4997.HEIC', 'image/heic', heic),
});
expect(res.statusCode).toBe(415);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('INVALID_INPUT');
expect(body.error).toMatch(/HEIC/);
});
it('rejects ftyp brands heic-decode cannot convert (e.g. heim) without invoking the converter', async () => {
heicConvert.mockClear();
const boundary = 'codeman-test-boundary';
const heim = Buffer.from('00000034667479706865696d00000000', 'hex');
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
headers: {
host: 'codeman.test',
origin: 'http://codeman.test',
'content-type': `multipart/form-data; boundary=${boundary}`,
},
payload: imageUploadBody(boundary, 'IMG_4998.HEIC', 'image/heic', heim),
});
expect(res.statusCode).toBe(415);
expect(JSON.parse(res.body).success).toBe(false);
expect(heicConvert).not.toHaveBeenCalled();
}); });
}); });