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
7 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 −1
manifest.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": {

view file

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",

view file

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",

view file

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 () => {

view file

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 /**

view file

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+});

view file

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) {

view file