mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
161 lines
6.7 KiB
TypeScript
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)),
|
|
};
|
|
}
|