mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +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:
Generated
+29
@@ -28,6 +28,8 @@
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^5.8.5",
|
||||
"heic-decode": "^2.1.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
@@ -7023,6 +7025,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/heic-decode": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/heic-decode/-/heic-decode-2.1.0.tgz",
|
||||
"integrity": "sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"libheif-js": "^1.19.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
@@ -7481,6 +7495,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-js": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz",
|
||||
"integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
@@ -7664,6 +7684,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libheif-js": {
|
||||
"version": "1.19.8",
|
||||
"resolved": "https://registry.npmjs.org/libheif-js/-/libheif-js-1.19.8.tgz",
|
||||
"integrity": "sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==",
|
||||
"license": "LGPL-3.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^5.8.5",
|
||||
"heic-decode": "^2.1.0",
|
||||
"jpeg-js": "^0.4.4",
|
||||
"node-pty": "^1.1.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^14.0.0",
|
||||
|
||||
Vendored
+24
@@ -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;
|
||||
}
|
||||
@@ -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})`)));
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
} from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import {
|
||||
mergeUnifiedSessions,
|
||||
@@ -201,6 +202,15 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
|
||||
return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
|
||||
case '.bmp':
|
||||
return data[0] === 0x42 && data[1] === 0x4d;
|
||||
case '.heic':
|
||||
case '.heif': {
|
||||
// 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;
|
||||
const brand = data.subarray(8, 12).toString('ascii');
|
||||
return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -1992,7 +2002,7 @@ export function registerSessionRoutes(
|
||||
// Paste Image (clipboard / drag-drop upload)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
|
||||
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
|
||||
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
|
||||
|
||||
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
||||
@@ -2084,7 +2094,7 @@ export function registerSessionRoutes(
|
||||
const origExt = extname(part.filename).toLowerCase();
|
||||
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
|
||||
}
|
||||
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp)$/);
|
||||
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
|
||||
if (mimeMatch) {
|
||||
const map: Record<string, string> = {
|
||||
png: '.png',
|
||||
@@ -2093,6 +2103,8 @@ export function registerSessionRoutes(
|
||||
webp: '.webp',
|
||||
gif: '.gif',
|
||||
bmp: '.bmp',
|
||||
heic: '.heic',
|
||||
heif: '.heif',
|
||||
};
|
||||
ext = map[mimeMatch[1]] ?? ext;
|
||||
}
|
||||
@@ -2105,14 +2117,27 @@ export function registerSessionRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
|
||||
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
|
||||
if (!imageMagicMatchesExt(imageBytes, ext)) {
|
||||
// Diagnostic: on some Android galleries (e.g. MIUI) a WebP/HEIF is
|
||||
// mislabeled as image/jpeg, so the declared ext passes the allowlist but
|
||||
// the magic bytes do not. Log the real header so format mismatches can be
|
||||
// pinned down without a reproduce-and-guess loop. The client now
|
||||
// re-encodes images to JPEG/PNG before upload, so this should be rare.
|
||||
// Route HEIC on the raw bytes, NOT the declared ext/mime: on some Android
|
||||
// galleries (e.g. MIUI) a HEIF comes back mislabeled as image/jpeg, and
|
||||
// browsers that cannot decode HEIF upload the original file as-is — so a
|
||||
// HEIC payload can arrive under any declared type. Filename and
|
||||
// Content-Type are attacker-supplied anyway; only the bytes are trusted.
|
||||
if (imageMagicMatchesExt(imageBytes, '.heic')) {
|
||||
try {
|
||||
imageBytes = await convertHeicToJpeg(imageBytes);
|
||||
ext = '.jpg';
|
||||
} catch (err: unknown) {
|
||||
console.warn(
|
||||
`[paste-image] HEIC conversion failed: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} error=${getErrorMessage(err)}`
|
||||
);
|
||||
reply.code(415);
|
||||
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')}`
|
||||
);
|
||||
|
||||
@@ -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