Files
houseplan-card/src/backdrop-probe.ts
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

161 lines
6.7 KiB
TypeScript

/**
* #39: header-level diagnostics for backdrop rasters.
*
* The whole point of this module is what it does NOT do: no ImageBitmap, no
* canvas, no allocation sized by anything read from the file. Dimensions and
* the alpha flag come from fixed-offset header fields of the bytes the dialog
* already holds, so the "this image is huge" warning costs nothing even for a
* 100-megapixel scan. Security stance (spec §Release/security): fields from
* the file are used for arithmetic only; implausible values and any parser
* exception collapse to 'unknown', which the UX treats as a warning, never as
* a silent pass.
*
* Thresholds live here as the single calibration point (spec §Константы).
* They were derived from the 2026-08-29 desktop-Chromium matrix
* (demo/benchmark_backdrop_decode.mjs) with a conservative tablet margin; a
* field recalibration is a one-file change.
*/
/** Warn when the decoded RGBA image would exceed this (≈32 MP). */
export const WARN_DECODED_BYTES = 128 * 1024 * 1024;
/** Browsers cap canvas/bitmap sides near 16384 px; beyond it decode is hopeless. */
export const HARD_DIMENSION = 16384;
/** Longest side of the reduced copy: enough for a 4K plan display. */
export const DOWNSCALE_TARGET_PX = 4096;
export const DOWNSCALE_JPEG_QUALITY = 0.9;
/** Give a slow tablet a real chance before declaring the decode dead. */
export const DOWNSCALE_TIMEOUT_MS = 10_000;
export type BackdropVerdict = 'safe' | 'warn' | 'hard' | 'unknown';
export interface BackdropProbe {
kind: BackdropVerdict;
width: number | null;
height: number | null;
alpha: boolean;
decodedBytes: number | null;
}
/** Sides beyond this are treated as parser garbage, not as a real image. */
const PLAUSIBLE_MAX_SIDE = 1_000_000;
const unknown = (): BackdropProbe =>
({ kind: 'unknown', width: null, height: null, alpha: false, decodedBytes: null });
const verdictFor = (width: number, height: number, alpha: boolean): BackdropProbe => {
if (!Number.isInteger(width) || !Number.isInteger(height)
|| width <= 0 || height <= 0
|| width > PLAUSIBLE_MAX_SIDE || height > PLAUSIBLE_MAX_SIDE) return unknown();
const decodedBytes = width * height * 4; // exact integers far below 2^53
const kind: BackdropVerdict = Math.max(width, height) > HARD_DIMENSION
? 'hard'
: decodedBytes > WARN_DECODED_BYTES ? 'warn' : 'safe';
return { kind, width, height, alpha, decodedBytes };
};
const u32be = (b: Uint8Array, at: number): number =>
((b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]) >>> 0;
const u16be = (b: Uint8Array, at: number): number => (b[at] << 8) | b[at + 1];
const u24le = (b: Uint8Array, at: number): number =>
b[at] | (b[at + 1] << 8) | (b[at + 2] << 16);
const u16le = (b: Uint8Array, at: number): number => b[at] | (b[at + 1] << 8);
const ascii = (b: Uint8Array, at: number, text: string): boolean => {
for (let i = 0; i < text.length; i++) if (b[at + i] !== text.charCodeAt(i)) return false;
return true;
};
/** PNG: IHDR is mandatory-first; colour types 4/6 or a tRNS chunk mean alpha. */
function probePng(b: Uint8Array): BackdropProbe {
if (b.length < 33 || u32be(b, 0) !== 0x89504e47 || u32be(b, 4) !== 0x0d0a1a0a) return unknown();
if (!ascii(b, 12, 'IHDR')) return unknown();
const width = u32be(b, 16);
const height = u32be(b, 20);
const colourType = b[25];
let alpha = colourType === 4 || colourType === 6;
// A palette/grayscale/rgb PNG may still carry transparency via tRNS. Walk
// chunk headers only (length+type, 8 bytes each hop) — never the payloads.
if (!alpha) {
let at = 33;
for (let hops = 0; hops < 64 && at + 8 <= b.length; hops++) {
const length = u32be(b, at);
if (ascii(b, at + 4, 'tRNS')) { alpha = true; break; }
if (ascii(b, at + 4, 'IDAT') || ascii(b, at + 4, 'IEND')) break;
if (length > b.length) break; // hostile length: stop walking, keep IHDR data
at += 12 + length;
}
}
return verdictFor(width, height, alpha);
}
/** JPEG: scan marker segments to the first SOF0..SOF15 frame header. */
function probeJpeg(b: Uint8Array): BackdropProbe {
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return unknown();
let at = 2;
for (let hops = 0; hops < 256 && at + 4 <= b.length; hops++) {
if (b[at] !== 0xff) { at++; continue; } // padding/garbage tolerance
const marker = b[at + 1];
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) { at += 2; continue; }
const isSof = marker >= 0xc0 && marker <= 0xcf
&& marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
if (isSof) {
if (at + 9 > b.length) return unknown();
return verdictFor(u16be(b, at + 7), u16be(b, at + 5), false); // JPEG never has alpha
}
if (marker === 0xda) return unknown(); // scan started before any SOF: give up
const length = u16be(b, at + 2);
if (length < 2) return unknown();
at += 2 + length;
}
return unknown();
}
/** WebP: VP8X (canvas size + alpha flag), lossy VP8 or lossless VP8L. */
function probeWebp(b: Uint8Array): BackdropProbe {
if (b.length < 30 || !ascii(b, 0, 'RIFF') || !ascii(b, 8, 'WEBP')) return unknown();
if (ascii(b, 12, 'VP8X')) {
const alpha = (b[20] & 0x10) !== 0;
return verdictFor(u24le(b, 24) + 1, u24le(b, 27) + 1, alpha);
}
if (ascii(b, 12, 'VP8 ')) {
// Lossy bitstream: 3-byte frame tag, then the 0x9d012a start code.
if (b[23] !== 0x9d || b[24] !== 0x01 || b[25] !== 0x2a) return unknown();
return verdictFor(u16le(b, 26) & 0x3fff, u16le(b, 28) & 0x3fff, false);
}
if (ascii(b, 12, 'VP8L')) {
if (b[20] !== 0x2f) return unknown();
// LE bit-packed: 14 bits width-1, 14 bits height-1, 1 bit alpha.
const raw = b[21] | (b[22] << 8) | (b[23] << 16) | (b[24] << 24);
const width = (raw & 0x3fff) + 1;
const height = ((raw >> 14) & 0x3fff) + 1;
const alpha = ((raw >> 28) & 1) === 1;
return verdictFor(width, height, alpha);
}
return unknown();
}
/**
* Classify a raster before anything expensive happens. `unknown` is the
* fail-closed answer for anything the parser cannot vouch for.
*/
export function probeBackdrop(bytes: Uint8Array, ext: string): BackdropProbe {
try {
if (ext === 'png') return probePng(bytes);
if (ext === 'jpg') return probeJpeg(bytes);
if (ext === 'webp') return probeWebp(bytes);
return unknown(); // svg never reaches the raster probe by contract
} catch {
return unknown();
}
}
/** Reduced-copy geometry: longest side to the target, aspect preserved. */
export function downscaleDimensions(
width: number, height: number, target = DOWNSCALE_TARGET_PX,
): { width: number; height: number } {
const scale = Math.min(1, target / Math.max(width, height));
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
}