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
This commit is contained in:
Codex
2026-08-29 10:13:09 +03:00
parent 4d73774779
commit 7c31725ac9
50 changed files with 1331 additions and 371 deletions
+56
View File
@@ -680,6 +680,62 @@ const MUTANT_DEFINITIONS = [
replace: ' this._languageFailureUnsub = undefined;',
}],
},
{
id: 'backdrop-probe-always-safe',
guard: 'node demo/smoke_backdrop_guard.mjs',
because: 'a probe that waves every raster through reopens the original hole — a 100 MP scan '
+ 'decodes unwarned and kills the tablet tab (#39 AC1)',
patches: [{
file: 'src/backdrop-pick.ts',
find: " if (probe.kind === 'safe') return { kind: 'pass', ext };",
replace: " return { kind: 'pass', ext };",
}],
},
{
id: 'backdrop-downscale-drops-alpha',
guard: 'node demo/smoke_backdrop_guard.mjs',
because: 'a transparent PNG reduced into JPEG silently paints the plan background black — '
+ 'alpha must survive the reduced copy (#39 AC2)',
patches: [{
file: 'src/backdrop-pick.ts',
find: " const alpha = state.probe.alpha;",
replace: ' const alpha = false;',
}],
},
{
id: 'backdrop-hard-demoted-to-warn',
guard: 'node demo/smoke_backdrop_guard.mjs',
because: 'beyond the 16384 px canvas cap the reduced copy CANNOT be built — offering it is a '
+ 'lie that ends in a decode failure (#39 AC4 phase 1)',
patches: [{
file: 'src/backdrop-probe.ts',
find: " const kind: BackdropVerdict = Math.max(width, height) > HARD_DIMENSION\n"
+ " ? 'hard'\n"
+ " : decodedBytes > WARN_DECODED_BYTES ? 'warn' : 'safe';",
replace: " const kind: BackdropVerdict = "
+ "decodedBytes > WARN_DECODED_BYTES ? 'warn' : 'safe';",
}],
},
{
id: 'backdrop-phase2-falls-back-to-original',
guard: 'node demo/smoke_backdrop_guard.mjs',
because: 'silently uploading the original the user just declined is the exact dishonesty the '
+ 'phase-2 contract forbids — staging must stay clean after a failed reduce (#39 AC4b)',
patches: [{
file: 'src/backdrop-pick.ts',
find: ' } catch {\n'
+ ' // Honest phase 2 (spec §UX): no silent fallback to the original the\n'
+ ' // user just declined — staging stays clean, the toast says what happened.\n'
+ ' close();\n'
+ " host._showToast(host._t('backdrop.downscale_failed'));\n"
+ ' }',
replace: ' } catch {\n'
+ ' const payload = await encodePlanFile(guard.file, guard.ext, guard.file.name);\n'
+ ' apply(payload);\n'
+ ' close();\n'
+ ' }',
}],
},
{
id: 'cold-view-vacuum-mapid-delegated',
guard: 'node demo/smoke_cold_view_vacuum.mjs',
+11
View File
@@ -44,6 +44,17 @@ export const SMOKE_LINKS = [
+ 'requests and four root render gates: neutral cold frame, atomic German commit, page-cache '
+ 'reuse and the bounded retry-to-English path cannot be proven from pure registry tests (#348)',
},
{
symbols: [
'probeBackdrop', 'classifyPlanFile', 'downscaleBackdrop', 'renderBackdropGuard',
'WARN_DECODED_BYTES', 'HARD_DIMENSION', 'DOWNSCALE_TARGET_PX',
],
smokes: ['smoke_backdrop_guard.mjs'],
because: 'the production-bundle scenario proves the guard end to end: header-only warning '
+ 'with zero decode calls, byte-identical keep-original path, a real 6200px reduce to 4096 '
+ 'with alpha preserved, the cancel-only hard dialog, the honest phase-2 failure (toast, '
+ 'clean staging, no silent original upload) and the SVG bypass (#39)',
},
{
symbols: ['vacMapIdWithFallback', 'vacMapIdFromAttrs', 'readVacTelemetry'],
smokes: ['smoke_cold_view_vacuum.mjs'],