26e37ef0c30c
bound codex scanner input before parse/decode/hash (PB-26-207, v5.0.63)
- time
- 2026-09-03 11:04 UTC · 1 month ago
- machine
- koh2
- agent
- muse-spark-1.3-contributor-free
- session
- PB-26-207 codex scan limits
- parent
- c7f4ca8b97d2
↓ peach-browser-26e37ef0.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/codex/codex.adversarial.test.ts+4 −2src/popup/codex/restore.ts+151 −19src/popup/codex/scan-limits.test.ts+385 −0src/popup/views/CodexRestoreView.tsx+5 −1manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.62",4+ "version": "5.0.63",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.0.62",5+ "version": "5.0.63",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.0.62",3+ "version": "5.0.63",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/popup/codex/codex.adversarial.test.ts+4 −2
@@ -176,12 +176,14 @@176176 expect(res).toEqual({ accepted: false, reason: "foreign_codex" });177177 });178178 179- it("tampered hash is rejected as bad_integrity", async () => {179+ it("malformed hash length is rejected as parse_error before hashing", async () => {180+ // PB-26-207: `h` must be exactly 32 chars; a short hash is malformed, so181+ // ChunkBuffer.add rejects it before base64 conversion or hashing.180182 const buf = new ChunkBuffer();181183 const env = await makeEnv({ index: 0, total: 5 });182184 const tampered = { ...env, h: "00000000" };183185 const res = await buf.add(tampered);184- expect(res).toEqual({ accepted: false, reason: "bad_integrity" });186+ expect(res).toEqual({ accepted: false, reason: "parse_error" });185187 });186188 187189 it("data tampered without rehashing is rejected", async () => {src/popup/codex/restore.ts+151 −19
@@ -22,8 +22,11 @@2222 */2323 2424 import {25+ CHUNK_SIZE,2526 CodexError,27+ CODEX_MAX_PARITY_SHARDS,2628 deriveShardCounts,29+ MAX_DATA_SHARDS_PER_STRIPE,2730 MAX_STRIPE_COUNT,2831 type ChunkEnvelope,2932 type CodexPayload,@@ -35,6 +38,37 @@3538 verifyChunkIntegrity,3639 } from "./helpers";3740 41+// ── PB-26-207 scan input bounds ─────────────────────────────────────────────42+// Every ceiling derives from the Codex format, not from available memory:43+// one shard is CHUNK_SIZE bytes, one envelope carries one shard, and the44+// buffer can never retain more than every stripe's every shard.45+46+/** Raw decoded scanner text accepted by parseRawScan (honest envelopes are ~0.7 KiB). */47+export const MAX_RAW_SCAN_CHARS = 4096;48+49+/** `d` may carry at most one shard's base64url expansion (350 B → 467 chars + padding tolerance). */50+export const MAX_CHUNK_B64_CHARS = Math.ceil(CHUNK_SIZE / 3) * 4;51+52+/** Explicit format headroom above the shard size for the decoded-chunk check. */53+export const SCAN_CHUNK_HEADROOM_BYTES = 64;54+55+/** Decoded `d` bytes accepted per chunk (honest chunks decode to exactly CHUNK_SIZE). */56+export const MAX_DECODED_CHUNK_BYTES = CHUNK_SIZE + SCAN_CHUNK_HEADROOM_BYTES;57+58+/** `c` is always a `PX-XXXX-XXXX` verify code; `h` is always a 128-bit hex prefix. */59+export const VERIFY_CODE_LENGTH = 12;60+export const CHUNK_HASH_LENGTH = 32;61+62+/** Most shards one stripe may declare (data ceiling + parity ceiling). */63+export const MAX_SHARDS_PER_STRIPE =64+ MAX_DATA_SHARDS_PER_STRIPE + CODEX_MAX_PARITY_SHARDS;65+66+/** Most chunks the buffer may retain: every stripe's every shard. */67+export const MAX_BUFFER_CHUNKS = MAX_STRIPE_COUNT * MAX_SHARDS_PER_STRIPE;68+69+/** Most decoded bytes the buffer may retain: every chunk at full shard size. */70+export const MAX_BUFFER_BYTES = MAX_BUFFER_CHUNKS * CHUNK_SIZE;71+3872 export interface ScannedChunk {3973 envelope: ChunkEnvelope;4074 bytes: Uint8Array;@@ -90,26 +124,66 @@90124 private verifyCode: string | null = null;91125 private stripeCount: number | null = null;92126 private readonly stripes = new Map<number, StripeState>();127+ private retainedBytes = 0;128+ private readonly maxBytes: number;129+ private readonly maxChunks: number;93130 131+ constructor(limits?: { maxBytes?: number; maxChunks?: number }) {132+ this.maxBytes = limits?.maxBytes ?? MAX_BUFFER_BYTES;133+ this.maxChunks = limits?.maxChunks ?? MAX_BUFFER_CHUNKS;134+ }135+94136 /**95137 * Add an envelope. Returns `{ accepted: true }` on a fresh accept, or96138 * `{ accepted: false, reason }` for any rejection. The buffer never throws —97139 * the scanner UI needs to keep running across bad frames.98140 *99141 * Accepts current v3 envelopes and rejects older paper formats.142+ *143+ * PB-26-207: every attacker-controlled length and index is validated before144+ * any base64 conversion, allocation, or hashing, and accepted bytes count145+ * against the aggregate buffer ceiling.100146 */101147 async add(envelope: ChunkEnvelope): Promise<ChunkAddResult> {102148 if (envelope.v !== 3) {103149 return { accepted: false, reason: "parse_error" };104150 }105151 152+ // Envelope shape and bounds before any decode/hash work. parseRawScan153+ // enforces the same limits on the camera path; this covers direct callers154+ // (PDF import, tests) and any envelope built outside the scanner.155+ if (156+ typeof envelope.c !== "string" ||157+ envelope.c.length !== VERIFY_CODE_LENGTH ||158+ typeof envelope.h !== "string" ||159+ envelope.h.length !== CHUNK_HASH_LENGTH ||160+ typeof envelope.d !== "string" ||161+ envelope.d.length === 0 ||162+ envelope.d.length > MAX_CHUNK_B64_CHARS ||163+ typeof envelope.p !== "boolean" ||164+ !Number.isInteger(envelope.s) ||165+ !Number.isInteger(envelope.S) ||166+ envelope.S < 1 ||167+ envelope.S > MAX_STRIPE_COUNT ||168+ envelope.s < 0 ||169+ envelope.s >= envelope.S ||170+ !Number.isInteger(envelope.i) ||171+ !Number.isInteger(envelope.t) ||172+ envelope.t < 1 ||173+ envelope.t > MAX_SHARDS_PER_STRIPE ||174+ envelope.i < 0 ||175+ envelope.i >= envelope.t176+ ) {177+ return { accepted: false, reason: "parse_error" };178+ }179+106180 // PCH-01-026 #2: decode `d` once, up front, so an out-of-alphabet base64107181 // payload becomes a parse_error rejection instead of a thrown exception out108182 // of add() (honouring the documented "never throws" contract).109183 // verifyChunkIntegrity also decodes `d`, so guarding here covers both that110184 // decode and the accept-path decode below, which reuses `decoded`.111185 const decoded = tryBase64UrlToBytes(envelope.d);112- if (decoded === null) {186+ if (decoded === null || decoded.length > MAX_DECODED_CHUNK_BYTES) {113187 return { accepted: false, reason: "parse_error" };114188 }115189 @@ -120,13 +194,6 @@120194 const stripe = envelope.s;121195 const stripeCount = envelope.S;122196 123- // PCH-01-011: `S` is attacker-controlled. Reject an out-of-range stripe124- // count before it can pin this.stripeCount and drive O(S) allocation in125- // stripeReadiness()/toStripeChunks() on the popup's render path.126- if (stripeCount < 1 || stripeCount > MAX_STRIPE_COUNT) {127- return { accepted: false, reason: "parse_error" };128- }129-130197 if (this.verifyCode === null) {131198 // First valid chunk establishes verifyCode and stripeCount. Reject132199 // subsequent envelopes whose `c` or `S` disagrees so two overlapping@@ -139,10 +206,6 @@139206 return { accepted: false, reason: "foreign_codex" };140207 }141208 142- if (stripe < 0 || stripe >= this.stripeCount!) {143- return { accepted: false, reason: "parse_error" };144- }145-146209 let stripeState = this.stripes.get(stripe);147210 if (!stripeState) {148211 // First envelope from a new stripe fixes that stripe's total + data@@ -163,20 +226,28 @@163226 return { accepted: false, reason: "parse_error" };164227 }165228 166- // PCH-01-026 #7: validate the index range BEFORE the duplicate lookup. An167- // out-of-range index is malformed, not a duplicate — classifying it by map168- // membership would mis-report it as "duplicate".169- if (envelope.i < 0 || envelope.i >= envelope.t) {170- return { accepted: false, reason: "parse_error" };171- }229+ // Duplicates are normal as the user pans across the same code, so they230+ // get their own reason rather than counting as malformed.172231 if (stripeState.chunks.has(envelope.i)) {173232 return { accepted: false, reason: "duplicate" };174233 }175234 235+ // Aggregate ceiling: every accepted chunk's actual decoded bytes count236+ // toward the stripes × shards × shard-size budget. Rejections above237+ // (duplicate, foreign, corrupt, malformed) return before this point, so238+ // they never inflate the counters.239+ if (240+ this.retainedBytes + decoded.length > this.maxBytes ||241+ this.size() + 1 > this.maxChunks242+ ) {243+ return { accepted: false, reason: "parse_error" };244+ }245+176246 stripeState.chunks.set(envelope.i, {177247 envelope,178248 bytes: decoded, // PCH-01-026 #2: reuse the single successful decode179249 });250+ this.retainedBytes += decoded.length;180251 return { accepted: true };181252 }182253 @@ -186,6 +257,11 @@186257 return count;187258 }188259 260+ /** Actual decoded bytes retained across all accepted chunks. */261+ bufferedBytes(): number {262+ return this.retainedBytes;263+ }264+189265 getContext(): ChunkBufferContext | null {190266 if (this.verifyCode === null || this.stripeCount === null) return null;191267 let total = 0;@@ -271,8 +347,16 @@271347 * stickers, old Codex prints) — so the caller must not assume any structure.272348 *273349 * Accepts current v3 envelopes. Older paper formats are rejected.350+ *351+ * PB-26-207: the raw text length is bounded before JSON.parse, and every352+ * envelope field length/range is validated before the caller performs any353+ * base64 conversion or hashing.274354 */275355 export function parseRawScan(raw: string): ParseRawScanResult {356+ if (typeof raw !== "string" || raw.length > MAX_RAW_SCAN_CHARS) {357+ return { error: "parse_error" };358+ }359+276360 let parsed: unknown;277361 try {278362 parsed = JSON.parse(raw);@@ -293,11 +377,15 @@293377 294378 const looksLikeEnvelope =295379 typeof obj.c === "string" &&380+ obj.c.length === VERIFY_CODE_LENGTH &&296381 Number.isInteger(obj.i) && // PCH-01-012: reject NaN/Infinity/fractional indices297382 Number.isInteger(obj.t) &&298383 typeof obj.p === "boolean" &&299384 typeof obj.d === "string" &&300- typeof obj.h === "string";385+ (obj.d as string).length > 0 &&386+ (obj.d as string).length <= MAX_CHUNK_B64_CHARS &&387+ typeof obj.h === "string" &&388+ (obj.h as string).length === CHUNK_HASH_LENGTH;301389 302390 if (!looksLikeEnvelope) {303391 return { error: "parse_error" };@@ -307,7 +395,51 @@307395 return { error: "parse_error" };308396 }309397 398+ const s = obj.s as number;399+ const S = obj.S as number;400+ const i = obj.i as number;401+ const t = obj.t as number;402+ if (403+ S < 1 ||404+ S > MAX_STRIPE_COUNT ||405+ s < 0 ||406+ s >= S ||407+ t < 1 ||408+ t > MAX_SHARDS_PER_STRIPE ||409+ i < 0 ||410+ i >= t411+ ) {412+ return { error: "parse_error" };413+ }414+310415 return obj as unknown as ChunkEnvelope;416+}417+418+/**419+ * PB-26-207: the camera decoder fires on every frame, so repeat callbacks420+ * arrive while the previous parse/add (including two SHA-256 hashes) is still421+ * in flight. The gate allows exactly one scan in flight and drops callbacks422+ * that arrive while busy — the same code is re-decoded on later frames, so423+ * dropped callbacks lose nothing while queueing unbounded work would.424+ */425+export function createScanGate(426+ process: (raw: string) => Promise<void>,427+): { onScan: (raw: string) => void } {428+ let busy = false;429+ return {430+ onScan(raw: string) {431+ if (busy) return;432+ busy = true;433+ void process(raw).then(434+ () => {435+ busy = false;436+ },437+ () => {438+ busy = false;439+ },440+ );441+ },442+ };311443 }312444 313445 /**src/popup/codex/scan-limits.test.ts+385 −0
@@ -0,0 +1,385 @@1+/**2+ * scan-limits.test.ts — PB-26-207 resource bounds on the Codex scan path.3+ *4+ * Every limit here is derived from the Codex format, not from available5+ * memory: raw scanner text ≤ 4 KiB before JSON.parse; `d` ≤ base64url6+ * expansion of one 350-byte shard before atob; decoded chunk ≤ shard size +7+ * explicit headroom; `c`/`h` exact lengths and integer ranges before any8+ * integrity work; buffer bytes/chunks ≤ MAX_STRIPE_COUNT × max shards per9+ * stripe × chunk size. The camera gate allows one parse/add in flight.10+ */11+12+import { beforeEach, describe, expect, it, vi } from "vitest";13+14+import {15+ CHUNK_SIZE,16+ CODEX_MAX_PARITY_SHARDS,17+ MAX_DATA_SHARDS_PER_STRIPE,18+ MAX_STRIPE_COUNT,19+ type ChunkEnvelope,20+} from "../../shared/codex-types";21+import { buildChunkEnvelope, bytesToBase64Url, sha256Hex } from "./helpers";22+import { _resetReedSolomonCacheForTest } from "./rs-loader";23+import {24+ CHUNK_HASH_LENGTH,25+ ChunkBuffer,26+ createScanGate,27+ MAX_BUFFER_BYTES,28+ MAX_BUFFER_CHUNKS,29+ MAX_CHUNK_B64_CHARS,30+ MAX_DECODED_CHUNK_BYTES,31+ MAX_RAW_SCAN_CHARS,32+ MAX_SHARDS_PER_STRIPE,33+ parseRawScan,34+ VERIFY_CODE_LENGTH,35+} from "./restore";36+37+beforeEach(() => {38+ _resetReedSolomonCacheForTest();39+});40+41+const VERIFY_CODE = "PX-TEST-ABCD";42+43+async function makeEnv(opts: {44+ bytes?: Uint8Array;45+ index?: number;46+ total?: number;47+ verifyCode?: string;48+ stripe?: number;49+ stripeCount?: number;50+} = {}): Promise<ChunkEnvelope> {51+ return buildChunkEnvelope(52+ opts.bytes ?? new Uint8Array(CHUNK_SIZE).fill(7),53+ opts.index ?? 0,54+ opts.total ?? 5,55+ false,56+ opts.verifyCode ?? VERIFY_CODE,57+ opts.stripe ?? 0,58+ opts.stripeCount ?? 1,59+ );60+}61+62+describe("PB-26-207 limit constants derive from the Codex format", () => {63+ it("raw scan ceiling is 4 KiB", () => {64+ expect(MAX_RAW_SCAN_CHARS).toBe(4096);65+ });66+67+ it("d ceiling is the base64url expansion of one shard", () => {68+ expect(MAX_CHUNK_B64_CHARS).toBe(Math.ceil(CHUNK_SIZE / 3) * 4);69+ });70+71+ it("decoded chunk ceiling is one shard plus explicit headroom", () => {72+ expect(MAX_DECODED_CHUNK_BYTES).toBeGreaterThan(CHUNK_SIZE);73+ expect(MAX_DECODED_CHUNK_BYTES - CHUNK_SIZE).toBeLessThanOrEqual(128);74+ });75+76+ it("aggregate ceiling is stripes × max shards per stripe × chunk size", () => {77+ expect(MAX_SHARDS_PER_STRIPE).toBe(78+ MAX_DATA_SHARDS_PER_STRIPE + CODEX_MAX_PARITY_SHARDS,79+ );80+ expect(MAX_BUFFER_CHUNKS).toBe(MAX_STRIPE_COUNT * MAX_SHARDS_PER_STRIPE);81+ expect(MAX_BUFFER_BYTES).toBe(MAX_BUFFER_CHUNKS * CHUNK_SIZE);82+ });83+84+ it("c and h lengths match the documented envelope format", () => {85+ expect(VERIFY_CODE_LENGTH).toBe("PX-XXXX-XXXX".length);86+ expect(CHUNK_HASH_LENGTH).toBe(32);87+ });88+});89+90+describe("PB-26-207 parseRawScan bounds", () => {91+ it("rejects oversized raw text without calling JSON.parse", async () => {92+ const env = await makeEnv();93+ const raw = JSON.stringify(env);94+ expect(raw.length).toBeLessThan(MAX_RAW_SCAN_CHARS);95+ const padded = raw.padEnd(MAX_RAW_SCAN_CHARS + 1, " ");96+ const spy = vi.spyOn(JSON, "parse");97+ try {98+ expect(parseRawScan(padded)).toEqual({ error: "parse_error" });99+ expect(spy).not.toHaveBeenCalled();100+ } finally {101+ spy.mockRestore();102+ }103+ });104+105+ it("accepts a valid envelope padded to exactly the raw ceiling", async () => {106+ const env = await makeEnv();107+ const padded = JSON.stringify(env).padEnd(MAX_RAW_SCAN_CHARS, " ");108+ expect(padded.length).toBe(MAX_RAW_SCAN_CHARS);109+ expect(parseRawScan(padded)).toMatchObject({ v: 3, c: VERIFY_CODE });110+ });111+112+ it("rejects non-string raw input without throwing", () => {113+ expect(parseRawScan(12345 as unknown as string)).toEqual({114+ error: "parse_error",115+ });116+ });117+118+ it("honest envelopes sit far below the raw ceiling", async () => {119+ const env = await makeEnv();120+ expect(JSON.stringify(env).length).toBeLessThan(MAX_RAW_SCAN_CHARS / 2);121+ });122+123+ it("rejects wrong-length c and h before integrity work", async () => {124+ const env = await makeEnv();125+ expect(126+ parseRawScan(JSON.stringify({ ...env, c: "PX-SHORT" })),127+ ).toEqual({ error: "parse_error" });128+ expect(129+ parseRawScan(JSON.stringify({ ...env, c: `${VERIFY_CODE}XX` })),130+ ).toEqual({ error: "parse_error" });131+ expect(parseRawScan(JSON.stringify({ ...env, h: "ABCDEF" }))).toEqual({132+ error: "parse_error",133+ });134+ expect(135+ parseRawScan(JSON.stringify({ ...env, h: `${env.h}FF` })),136+ ).toEqual({ error: "parse_error" });137+ });138+139+ it("rejects oversized d before decoding", async () => {140+ const env = await makeEnv();141+ expect(142+ parseRawScan(143+ JSON.stringify({ ...env, d: `${env.d}AAAA` }),144+ ),145+ ).toEqual({ error: "parse_error" });146+ });147+148+ it("rejects out-of-range stripe/chunk indices", async () => {149+ const env = await makeEnv();150+ const cases: Array<Partial<ChunkEnvelope>> = [151+ { S: 0 },152+ { S: MAX_STRIPE_COUNT + 1 },153+ { s: 1, S: 1 },154+ { s: -1 },155+ { i: -1 },156+ { i: env.t },157+ { t: 0 },158+ { t: MAX_SHARDS_PER_STRIPE + 1 },159+ ];160+ for (const patch of cases) {161+ expect(162+ parseRawScan(JSON.stringify({ ...env, ...patch })),163+ JSON.stringify(patch),164+ ).toEqual({ error: "parse_error" });165+ }166+ });167+168+ it("accepts boundary indices: S=64, s=S-1, i=t-1", async () => {169+ const env = await makeEnv({ index: 4, total: 5, stripe: 63, stripeCount: 64 });170+ expect(parseRawScan(JSON.stringify(env))).toMatchObject({171+ v: 3,172+ S: 64,173+ s: 63,174+ i: 4,175+ t: 5,176+ });177+ });178+});179+180+describe("PB-26-207 ChunkBuffer.add validates before decode/hash", () => {181+ it("rejects oversized d with an honest hash as parse_error, not bad_integrity", async () => {182+ // 400-byte chunk: d is ~534 chars and the hash genuinely matches, so a183+ // post-hash check would accept it. parse_error proves the length gate ran184+ // before atob/alloc/hash.185+ const bytes = new Uint8Array(400).fill(9);186+ const env = await buildChunkEnvelope(bytes, 0, 5, false, VERIFY_CODE);187+ expect(env.d.length).toBeGreaterThan(MAX_CHUNK_B64_CHARS);188+ const buf = new ChunkBuffer();189+ expect(await buf.add(env)).toEqual({190+ accepted: false,191+ reason: "parse_error",192+ });193+ expect(buf.size()).toBe(0);194+ expect(buf.bufferedBytes()).toBe(0);195+ });196+197+ it("d boundary: 468 chars accepted, 469 rejected", async () => {198+ // 351 bytes encode to exactly 468 unpadded chars and stay within the199+ // decoded headroom; a padded 469-char twin of a 350-byte chunk is not.200+ const bytes351 = new Uint8Array(351).fill(3);201+ const atLimit = await buildChunkEnvelope(bytes351, 0, 5, false, VERIFY_CODE);202+ expect(atLimit.d.length).toBe(MAX_CHUNK_B64_CHARS);203+ expect((await new ChunkBuffer().add(atLimit)).accepted).toBe(true);204+205+ const base = await makeEnv();206+ const over = { ...base, d: `${base.d}==` };207+ expect(over.d.length).toBe(MAX_CHUNK_B64_CHARS + 1);208+ const buf = new ChunkBuffer();209+ expect(await buf.add(over)).toEqual({210+ accepted: false,211+ reason: "parse_error",212+ });213+ expect(buf.size()).toBe(0);214+ });215+216+ it("rejects wrong-length c/h and non-boolean p without hashing", async () => {217+ const env = await makeEnv();218+ for (const patch of [219+ { c: "PX-SHORT" },220+ { c: `${VERIFY_CODE}XX` },221+ { h: "00" },222+ { h: `${env.h}FF` },223+ { p: "yes" },224+ ]) {225+ const buf = new ChunkBuffer();226+ expect(await buf.add({ ...env, ...patch } as ChunkEnvelope)).toEqual({227+ accepted: false,228+ reason: "parse_error",229+ });230+ expect(buf.getContext()).toBeNull();231+ }232+ });233+234+ it("rejects an oversized stripe count before decoding", async () => {235+ const env = await buildChunkEnvelope(236+ new Uint8Array([1, 2, 3]),237+ 0,238+ 5,239+ false,240+ VERIFY_CODE,241+ 0,242+ MAX_STRIPE_COUNT + 1,243+ );244+ const buf = new ChunkBuffer();245+ expect(await buf.add(env)).toEqual({246+ accepted: false,247+ reason: "parse_error",248+ });249+ expect(buf.getContext()).toBeNull();250+ });251+252+ it("tracks decoded bytes on accept only", async () => {253+ const buf = new ChunkBuffer();254+ expect(buf.bufferedBytes()).toBe(0);255+ const first = await makeEnv({ index: 0, bytes: new Uint8Array(100).fill(1) });256+ expect((await buf.add(first)).accepted).toBe(true);257+ expect(buf.bufferedBytes()).toBe(100);258+259+ // Duplicate, foreign, corrupt, and malformed inputs leave the counter flat.260+ expect(await buf.add(first)).toEqual({261+ accepted: false,262+ reason: "duplicate",263+ });264+ const foreign = await makeEnv({ index: 1, verifyCode: "PX-OTHR-XXXX" });265+ expect(await buf.add(foreign)).toEqual({266+ accepted: false,267+ reason: "foreign_codex",268+ });269+ const badHash = await makeEnv({ index: 1 });270+ expect(await buf.add({ ...badHash, h: `FF${badHash.h.slice(2)}` })).toEqual(271+ {272+ accepted: false,273+ reason: "bad_integrity",274+ },275+ );276+ const malformed = await makeEnv({ index: 1 });277+ expect(await buf.add({ ...malformed, d: "x".repeat(9999) })).toEqual({278+ accepted: false,279+ reason: "parse_error",280+ });281+ expect(buf.bufferedBytes()).toBe(100);282+ expect(buf.size()).toBe(1);283+ });284+285+ it("enforces the aggregate byte/chunk ceiling: exact fits, +1 fails", async () => {286+ // A 1-chunk, 350-byte budget keeps the enforcement path fast: the287+ // production ceiling uses the same comparison against larger constants.288+ const buf = new ChunkBuffer({ maxBytes: CHUNK_SIZE, maxChunks: 1 });289+ const first = await makeEnv({ index: 0 });290+ expect((await buf.add(first)).accepted).toBe(true);291+ expect(buf.bufferedBytes()).toBe(CHUNK_SIZE);292+293+ const second = await makeEnv({ index: 1 });294+ expect(await buf.add(second)).toEqual({295+ accepted: false,296+ reason: "parse_error",297+ });298+ expect(buf.size()).toBe(1);299+ expect(buf.bufferedBytes()).toBe(CHUNK_SIZE);300+ });301+302+ it("honest 350-byte shards count toward the real aggregate ceiling", async () => {303+ const buf = new ChunkBuffer();304+ const env = await makeEnv({ index: 0 });305+ expect((await buf.add(env)).accepted).toBe(true);306+ expect(buf.bufferedBytes()).toBe(CHUNK_SIZE);307+ expect(buf.bufferedBytes()).toBeLessThanOrEqual(MAX_BUFFER_BYTES);308+ expect(buf.size()).toBeLessThanOrEqual(MAX_BUFFER_CHUNKS);309+ });310+});311+312+describe("PB-26-207 camera scan gate", () => {313+ it("runs one scan at a time and drops repeats while busy", async () => {314+ let release!: () => void;315+ const gate = new Promise<void>((resolve) => {316+ release = resolve;317+ });318+ let calls = 0;319+ const scan = createScanGate(async () => {320+ calls++;321+ await gate;322+ });323+324+ scan.onScan("first");325+ scan.onScan("second");326+ scan.onScan("third");327+ await Promise.resolve();328+ await Promise.resolve();329+ expect(calls).toBe(1);330+331+ release();332+ await gate;333+ await Promise.resolve();334+ await Promise.resolve();335+ // Dropped callbacks are never replayed; the next frame starts new work.336+ scan.onScan("fourth");337+ await Promise.resolve();338+ await Promise.resolve();339+ expect(calls).toBe(2);340+ });341+342+ it("a throwing scan resets the gate without an unhandled rejection", async () => {343+ const errors: unknown[] = [];344+ const scan = createScanGate(async () => {345+ throw new Error("boom");346+ });347+ scan.onScan("bad");348+ await new Promise((resolve) => setTimeout(resolve, 10));349+ // Gate is usable again: a second failure would hang or throw here.350+ let second = 0;351+ const ok = createScanGate(async () => {352+ second++;353+ });354+ ok.onScan("good");355+ await new Promise((resolve) => setTimeout(resolve, 10));356+ expect(second).toBe(1);357+ expect(errors).toEqual([]);358+ });359+});360+361+describe("PB-26-207 existing fixtures remain valid", () => {362+ it("a generator-built envelope passes every new gate", async () => {363+ const bytes = new Uint8Array(CHUNK_SIZE).fill(11);364+ const env = await buildChunkEnvelope(bytes, 2, 5, false, VERIFY_CODE);365+ expect(env.d.length).toBeLessThanOrEqual(MAX_CHUNK_B64_CHARS);366+ const parsed = parseRawScan(JSON.stringify(env));367+ expect("error" in parsed).toBe(false);368+ const buf = new ChunkBuffer();369+ expect((await buf.add(parsed as ChunkEnvelope)).accepted).toBe(true);370+ expect(buf.bufferedBytes()).toBe(CHUNK_SIZE);371+ });372+373+ it("base64url helpers still round-trip the shard size", () => {374+ const bytes = new Uint8Array(CHUNK_SIZE).fill(5);375+ const encoded = bytesToBase64Url(bytes);376+ expect(encoded.length).toBeLessThanOrEqual(MAX_CHUNK_B64_CHARS);377+ });378+379+ it("chunk hash helper still emits the exact documented length", async () => {380+ const hash = (381+ await sha256Hex(new Uint8Array(CHUNK_SIZE).fill(5))382+ ).slice(0, 32);383+ expect(hash).toHaveLength(CHUNK_HASH_LENGTH);384+ });385+});src/popup/views/CodexRestoreView.tsx+5 −1
@@ -17,6 +17,7 @@1717 import { sendMessage } from "../../shared/bridge";1818 import {1919 ChunkBuffer,20+ createScanGate,2021 parseRawScan,2122 reconstructAndVerify,2223 } from "../codex/restore";@@ -163,9 +164,12 @@163164 await Promise.resolve();164165 if (!videoEl) return;165166 try {167+ // PB-26-207: the decoder fires per frame — gate to one parse/add in168+ // flight, dropping repeats while busy instead of queueing work.169+ const gate = createScanGate((text) => handleScan(text));166170 scanner = await startScanner({167171 videoEl,168- onScan: (text) => void handleScan(text),172+ onScan: (text) => gate.onScan(text),169173 onError: (err) => handleScannerError(err),170174 });171175 } catch (err) {