mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
Merge PR #151 from aakhter/cod-167-heic-jpeg-conversion: convert HEIC paste uploads to JPEG
Includes review fixes: worker-thread conversion with resourceLimits + timeout, global conversion-limiter cap, 64MP pre-decode bomb guard, magic-byte detection (covers mislabeled Android HEIF).
This commit is contained in:
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
@@ -23,10 +27,15 @@ import { Session } from '../../src/session.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('../../src/web/heic-jpeg-converter.js', () => ({ convertHeicToJpeg: heicConvert }));
|
||||
|
||||
// In-memory remote store so remote-case tests can inject hosts/cases without real JSON files.
|
||||
const remoteStore = vi.hoisted(() => ({
|
||||
@@ -63,6 +72,9 @@ async function createEnvelopeHarness(
|
||||
): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
await app.register(fastifyMultipart, {
|
||||
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 },
|
||||
});
|
||||
|
||||
const ctx = createMockRouteContext();
|
||||
registerFn(app, ctx);
|
||||
@@ -144,6 +156,123 @@ describe('session-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/paste-image ==========
|
||||
|
||||
describe('POST /api/sessions/:id/paste-image', () => {
|
||||
function imageUploadBody(boundary: string, filename: string, mimetype: string, imageBytes: Buffer): Buffer {
|
||||
return Buffer.concat([
|
||||
Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="image"; filename="${filename}"\r\n` +
|
||||
`Content-Type: ${mimetype}\r\n\r\n`
|
||||
),
|
||||
imageBytes,
|
||||
Buffer.from(`\r\n--${boundary}--\r\n`),
|
||||
]);
|
||||
}
|
||||
|
||||
it('converts HEIC paste images to JPEG attachments when browser-side normalization falls back', async () => {
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'codeman-heic-'));
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
heicConvert.mockClear();
|
||||
|
||||
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_4996.HEIC', 'image/heic', 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('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();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions ==========
|
||||
|
||||
describe('GET /api/sessions', () => {
|
||||
|
||||
Reference in New Issue
Block a user