mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
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:
@@ -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).
|
||||
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')));
|
||||
vi.mock('node:child_process', async (orig) => {
|
||||
const actual = await orig<typeof import('node:child_process')>();
|
||||
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';
|
||||
|
||||
@@ -170,7 +173,81 @@ describe('session-routes', () => {
|
||||
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({ 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user