Files
houseplan-card/test/backdrop-probe.test.mjs
T
Codex 7c31725ac9 feat: warn about huge backdrops and offer a safe reduced copy (#39)
A picked raster is now classified from its HEADER BYTES ONLY before anything
heavy happens: src/backdrop-probe.ts parses PNG IHDR (+colour type/tRNS for
alpha), JPEG SOF and WebP VP8/VP8L/VP8X at fixed offsets, never using a file
field as an allocation size; hostile or truncated headers collapse to
'unknown', which warns without numbers instead of passing silently. The
thresholds live in that module as the single calibration point
(WARN_DECODED_BYTES 128 MiB ≈ 32 MP, HARD_DIMENSION 16384 — the browser
canvas cap, DOWNSCALE_TARGET_PX 4096), derived from the desktop-Chromium
matrix now committed as demo/benchmark_backdrop_decode.mjs with a
conservative tablet margin documented in the spec.

The shared pick flow (src/backdrop-pick.ts) feeds BOTH lazy runtimes — the
editor space dialog and the onboarding first-space dialog — so the guard
cannot drift between them, and nothing of it enters the eager View graph.
Warn shows the real numbers and three actions; the reduced copy decodes
EXIF-aware, keeps aspect and alpha (PNG stays PNG, opaque becomes JPEG
q0.9) and flows through the ordinary planFile → upload path. Hard has two
phases with one outcome: beyond 16384 px only Cancel; a failed or timed-out
(10 s) reduce closes with a toast, clean staging and NO silent fallback to
the original the user just declined. SVG never reaches the probe. The safe
path swaps the manual byte-loop base64 for FileReader — half the JS-heap
peak on every upload, byte-identical output (parity asserted in the smoke).

Proofs: header-table units incl. a fuzz set of hostile headers and ±1
threshold bounds; smoke_backdrop_guard on the real bundle — zero decode
calls before the choice, byte parity of keep-original, a real 6200 px
reduce to 4096 for both alpha and opaque branches, cancel-only hard
dialog, both phase-2 failures (reject and hang under the test-only timeout
override), re-pick after refusal, SVG bypass; four registry mutants
(probe-always-safe, alpha-dropped, hard-demoted, phase-2 silent fallback).
Spec anchor corrected alongside: the server plan limit is 8 MB
(MAX_PLAN_BYTES), attachments are the 50 MB path — an 8 MB JPEG is easily
80-160 MP decoded, so the client-side guard stays the primary defence.

Issue: #39
User-Visible: yes
2026-08-29 10:13:09 +03:00

134 lines
6.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #39: header-level raster diagnostics. Every case here proves the parser
// works on BYTES ONLY — no canvas, no ImageBitmap, no allocation sized by the
// file. Fixtures are handcrafted headers a few dozen bytes long even when they
// claim a 400-megapixel image.
import assert from 'node:assert/strict';
import test from 'node:test';
import {
HARD_DIMENSION, WARN_DECODED_BYTES, DOWNSCALE_TARGET_PX,
downscaleDimensions, probeBackdrop,
} from '../test-build/backdrop-probe.js';
const u32 = (value) => [(value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255];
const u16 = (value) => [(value >>> 8) & 255, value & 255];
const asciiBytes = (text) => [...text].map((ch) => ch.charCodeAt(0));
const png = (width, height, { colourType = 2, chunks = [] } = {}) => Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
...u32(13), ...asciiBytes('IHDR'),
...u32(width), ...u32(height),
8, colourType, 0, 0, 0,
...u32(0), // IHDR CRC (не проверяется парсером)
...chunks,
...u32(0), ...asciiBytes('IEND'), ...u32(0),
]);
const pngChunk = (type, length, payload = []) => [
...u32(length), ...asciiBytes(type), ...payload, ...u32(0),
];
const jpeg = (width, height, { progressive = false } = {}) => Uint8Array.from([
0xff, 0xd8,
0xff, 0xe0, ...u16(16), ...asciiBytes('JFIF\0'), 1, 1, 0, ...u16(1), ...u16(1), 0, 0,
0xff, progressive ? 0xc2 : 0xc0, ...u16(11), 8, ...u16(height), ...u16(width), 1, 0x11, 0,
]);
const webpVp8x = (width, height, { alpha = false } = {}) => {
const w = width - 1, h = height - 1;
return Uint8Array.from([
...asciiBytes('RIFF'), 0, 0, 0, 0, ...asciiBytes('WEBP'),
...asciiBytes('VP8X'), 10, 0, 0, 0,
alpha ? 0x10 : 0x00, 0, 0, 0,
w & 255, (w >> 8) & 255, (w >> 16) & 255,
h & 255, (h >> 8) & 255, (h >> 16) & 255,
]);
};
const webpVp8 = (width, height) => Uint8Array.from([
...asciiBytes('RIFF'), 0, 0, 0, 0, ...asciiBytes('WEBP'),
...asciiBytes('VP8 '), 0, 0, 0, 0,
0, 0, 0, 0x9d, 0x01, 0x2a,
width & 255, (width >> 8) & 255, height & 255, (height >> 8) & 255,
]);
const webpVp8l = (width, height, { alpha = false } = {}) => {
const raw = ((width - 1) & 0x3fff) | (((height - 1) & 0x3fff) << 14) | ((alpha ? 1 : 0) << 28);
return Uint8Array.from([
...asciiBytes('RIFF'), 0, 0, 0, 0, ...asciiBytes('WEBP'),
...asciiBytes('VP8L'), 0, 0, 0, 0,
0x2f, raw & 255, (raw >> 8) & 255, (raw >> 16) & 255, (raw >>> 24) & 255,
0, 0, 0, 0, 0,
]);
};
test('пороги: безопасно / предупреждение / жёсткий отказ (#39 AC1)', () => {
// ровно на границе 128 МиБ: 5792×5792×4 = 134 189 056 < 134 217 728
const safeSide = 5792;
assert.equal(probeBackdrop(png(safeSide, safeSide), 'png').kind, 'safe');
// +1 по стороне переваливает порог
const warn = probeBackdrop(png(safeSide + 1, safeSide + 1), 'png');
assert.equal(warn.kind, 'warn');
assert.equal(warn.decodedBytes, (safeSide + 1) ** 2 * 4);
assert.ok(warn.decodedBytes > WARN_DECODED_BYTES);
// ровно 16384 по стороне — ещё не hard (порог строгий), а 16384×100 мал по памяти
assert.equal(probeBackdrop(png(HARD_DIMENSION, 100), 'png').kind, 'safe');
assert.equal(probeBackdrop(png(HARD_DIMENSION + 1, 100), 'png').kind, 'hard');
assert.equal(probeBackdrop(png(100, HARD_DIMENSION + 1), 'png').kind, 'hard');
});
test('PNG: alpha по colour type и по tRNS (#39 AC2-вход)', () => {
assert.equal(probeBackdrop(png(6000, 6000, { colourType: 6 }), 'png').alpha, true);
assert.equal(probeBackdrop(png(6000, 6000, { colourType: 4 }), 'png').alpha, true);
assert.equal(probeBackdrop(png(6000, 6000, { colourType: 2 }), 'png').alpha, false);
const trns = png(6000, 6000, { colourType: 3, chunks: pngChunk('tRNS', 1, [0]) });
assert.equal(probeBackdrop(trns, 'png').alpha, true);
const idatFirst = png(6000, 6000, {
colourType: 3, chunks: [...pngChunk('IDAT', 2, [0, 0]), ...pngChunk('tRNS', 1, [0])],
});
assert.equal(probeBackdrop(idatFirst, 'png').alpha, false, 'после IDAT tRNS не ищем');
});
test('JPEG: baseline и progressive SOF, alpha всегда false', () => {
const base = probeBackdrop(jpeg(9000, 7000), 'jpg');
assert.deepEqual([base.width, base.height, base.alpha], [9000, 7000, false]);
const prog = probeBackdrop(jpeg(6500, 6500, { progressive: true }), 'jpg');
assert.equal(prog.kind, 'warn');
});
test('WebP: VP8X с alpha-флагом, lossy VP8, lossless VP8L', () => {
const x = probeBackdrop(webpVp8x(8000, 8000, { alpha: true }), 'webp');
assert.deepEqual([x.width, x.height, x.alpha, x.kind], [8000, 8000, true, 'warn']);
const lossy = probeBackdrop(webpVp8(4000, 3000), 'webp');
assert.deepEqual([lossy.width, lossy.height, lossy.kind], [4000, 3000, 'safe']);
const lossless = probeBackdrop(webpVp8l(6100, 6100, { alpha: true }), 'webp');
assert.deepEqual([lossless.width, lossless.alpha, lossless.kind], [6100, true, 'warn']);
});
test('битые и враждебные заголовки → unknown, никогда не исключение (#39 AC6, security)', () => {
const hostile = [
['png', new Uint8Array(0)],
['png', png(6000, 6000).slice(0, 20)], // усечён посреди IHDR
['png', Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])],
['png', png(0, 6000)], // нулевая ширина
['png', png(0x7fffffff, 0x7fffffff)], // неправдоподобные стороны
['jpg', Uint8Array.from([0xff, 0xd8, 0xff, 0xda, 0, 4, 0, 0])], // SOS до SOF
['jpg', Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 0, 1])], // длина сегмента < 2
['jpg', jpeg(9000, 7000).slice(0, 22)], // обрезан до SOF-полей
['webp', Uint8Array.from(asciiBytes('RIFF....WEBPVP8X'))], // огрызок VP8X
['webp', webpVp8(4000, 3000).slice(0, 24)],
['gif', Uint8Array.from(asciiBytes('GIF89a'))], // не поддерживаемый ext
];
for (const [ext, bytes] of hostile) {
const probe = probeBackdrop(bytes, ext);
assert.equal(probe.kind, 'unknown', `${ext}/${bytes.length}B`);
assert.equal(probe.decodedBytes, null);
}
// враждебная длина чанка не мешает вердикту по IHDR
const evil = png(6000, 6000, { colourType: 2, chunks: pngChunk('iTXt', 0xffffffff >>> 0) });
assert.equal(probeBackdrop(evil, 'png').kind, 'warn');
});
test('downscaleDimensions: aspect и цель (#39 AC2)', () => {
assert.deepEqual(downscaleDimensions(10000, 5000), { width: DOWNSCALE_TARGET_PX, height: 2048 });
assert.deepEqual(downscaleDimensions(3000, 2000), { width: 3000, height: 2000 }, 'меньше цели — не трогаем');
assert.deepEqual(downscaleDimensions(5000, 10000), { width: 2048, height: DOWNSCALE_TARGET_PX });
});