bc6cdb06f264

fix: PCH-01-026 ten minor code-quality items — remove dead save.ts MutationObserver; ChunkBuffer.add never-throws via tryBase64UrlToBytes; generate.ts step renumber; sum per-stripe t for PDF totalExpected; parseCSV single-parse {entries,totalItems}; chunk range-before-duplicate; reed-solomon try/finally frees; surface+close on sync WS error (drop dead errors array); drop SAVE_NOTE as-any cast (026-06 already resolved by PCH-01-019) (v3.8.1)

time
2026-06-10 20:31 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
pch-01 fix integration
parent
0511f34134fc
11 files src/background/messages.ts+4 −3src/content/save.ts+2 −8src/popup/codex/codex.adversarial.test.ts+19 −0src/popup/codex/generate.ts+1 −1src/popup/codex/helpers.ts+14 −0src/popup/codex/pdf-import.ts+15 −4src/popup/codex/reed-solomon.ts+49 −40src/popup/codex/restore.ts+18 −5src/shared/chirp-sync-transport.ts+11 −5src/shared/import.test.ts+18 −0src/shared/import.ts+7 −7
src/background/messages.ts+4 −3
@@ -542,13 +542,14 @@542542           await vault.updateEntry(id, { title: title || "Untitled", notes: body, modifiedAt: now });543543           return { ok: true, data: vault.getEntryById(id) };544544         } else {545+          // PCH-01-026 #10: addEntry stamps its own createdAt/modifiedAt546+          // (EntryDraft omits both), so passing them was the only reason the547+          // `as any` cast existed. Dropping them makes the call type-check.545548           const entry = await vault.addEntry({546549             type: "note",547550             title: title || "Untitled",548551             notes: body,549-            createdAt: now,550-            modifiedAt: now,551-          } as any);552+          });552553           return { ok: true, data: entry };553554         }554555       }

view file

src/content/save.ts+2 −8
@@ -264,14 +264,8 @@264264 }265265 266266 export function setupSaveDetection(): void {267-  let lastUrl = location.href;268-269-  new MutationObserver(() => {270-    if (location.href !== lastUrl) {271-      lastUrl = location.href;272-    }273-  }).observe(document.body, { childList: true, subtree: true });274-267+  // PCH-01-026 #1: removed a dead no-op URL-tracking MutationObserver that ran268+  // on every DOM mutation only to update a `lastUrl` variable nothing consumed.275269   document.addEventListener("submit", async (e) => {276270     const form = e.target as HTMLFormElement;277271     if (!form) return;

view file

src/popup/codex/codex.adversarial.test.ts+19 −0
@@ -274,6 +274,25 @@274274     expect(parseRawScan(JSON.stringify({ ...env, s: 0.5 }))).toEqual({ error: "parse_error" });275275     expect(parseRawScan(JSON.stringify({ ...env, S: 1.5 }))).toEqual({ error: "parse_error" });276276   });277+278+  // PCH-01-026 #2: ChunkBuffer.add must never throw — an out-of-alphabet base64279+  // payload becomes a parse_error rejection.280+  it("chunk_buffer_add_does_not_throw_on_invalid_base64", async () => {281+    const buf = new ChunkBuffer();282+    const env = await makeEnv({ index: 0, total: 5 });283+    const res = await buf.add({ ...env, d: "!!!@@" });284+    expect(res).toEqual({ accepted: false, reason: "parse_error" });285+    expect(buf.getContext()).toBeNull();286+  });287+288+  // PCH-01-026 #7: an out-of-range index is malformed (parse_error), never289+  // mis-reported as a duplicate.290+  it("chunk_buffer_out_of_range_index_reports_parse_error", async () => {291+    const buf = new ChunkBuffer();292+    const env = await makeEnv({ index: 5, total: 5 }); // i === t, one past the end293+    const res = await buf.add(env);294+    expect(res).toEqual({ accepted: false, reason: "parse_error" });295+  });277296 });278297 279298 // ── verifyChunkIntegrity adversarial ────────────────────────────────────────

view file

src/popup/codex/generate.ts+1 −1
@@ -169,7 +169,7 @@169169   };170170   const pdfBytes = await renderCodexPDF(aztecPngs, manifest);171171 172-  // STEP 10 — sanity check the PDF172+  // STEP 9 — sanity check the PDF173173   if (!pdfBytes || pdfBytes.length < 1000) {174174     throw new CodexError(175175       "PDF_GENERATION_FAILED",

view file

src/popup/codex/helpers.ts+14 −0
@@ -48,6 +48,20 @@4848   return out;4949 }5050 51+/**52+ * Total variant of base64UrlToBytes: returns null instead of throwing on53+ * invalid input (atob throws InvalidCharacterError on out-of-alphabet chars).54+ * Used by ChunkBuffer.add, whose documented contract is "never throws"55+ * (PCH-01-026 #2).56+ */57+export function tryBase64UrlToBytes(s: string): Uint8Array | null {58+  try {59+    return base64UrlToBytes(s);60+  } catch {61+    return null;62+  }63+}64+5165 // ── SHA-256 ─────────────────────────────────────────────────────────────────5266 5367 /**

view file

src/popup/codex/pdf-import.ts+15 −4
@@ -85,7 +85,7 @@8585 export interface PDFImportResult {8686   /** Number of envelopes successfully decoded across all pages. */8787   chunksFound: number;88-  /** Total chunk count printed on the Codex, taken from the first envelope. */88+  /** Total chunk count printed on the Codex: sum of every stripe's `t`. */8989   totalExpected: number | null;9090   /** Decoded envelopes. Caller feeds these into ChunkBuffer. */9191   envelopes: ChunkEnvelope[];@@ -288,7 +288,10 @@288288   // Key is `${stripe}:${chunkIndex}` so multi-stripe Codexes don't treat289289   // stripe 1's chunk 0 as a duplicate of stripe 0's chunk 0.290290   const seenIndices = new Set<string>();291-  let totalExpected: number | null = null;291+  // PCH-01-026 #4: `t` is the per-stripe chunk total, not the Codex total. Track292+  // each stripe's declared total once and sum after the scan so a multi-stripe293+  // Codex reports the true global count, not just stripe 0's.294+  const stripeTotalsForExpected = new Map<number, number>();292295 293296   let pdf: Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>;294297   try {@@ -344,8 +347,10 @@344347       const pageEnvelopes = await scanCanvasGrid(canvas, seenIndices, renderScale);345348       for (const env of pageEnvelopes) envelopes.push(env);346349 347-      if (totalExpected === null && pageEnvelopes.length > 0) {348-        totalExpected = pageEnvelopes[0]!.t;350+      for (const env of pageEnvelopes) {351+        const stripe = env.v === 3 ? (env.s ?? 0) : 0;352+        // Each stripe's `t` is constant across its envelopes; last write wins.353+        stripeTotalsForExpected.set(stripe, env.t);349354       }350355     } catch (err) {351356       errors.push(`Page ${pageNum}: ${err instanceof Error ? err.message : String(err)}`);@@ -384,6 +389,12 @@384389           : [],385390     };386391   }392+393+  // PCH-01-026 #4: the Codex total is the sum of every stripe's `t`.394+  const totalExpected =395+    stripeTotalsForExpected.size > 0396+      ? Array.from(stripeTotalsForExpected.values()).reduce((sum, t) => sum + t, 0)397+      : null;387398 388399   return {389400     chunksFound: envelopes.length,

view file

src/popup/codex/reed-solomon.ts+49 −40
@@ -76,26 +76,30 @@7676     const exports = this.exports;7777     const shardsLength = shards.length;7878     const shardsPointer = exports.__wbindgen_malloc(shardsLength);79-    this.getUint8Memory().set(shards, shardsPointer);79+    // PCH-01-026 #8: free the malloc'd region even if a step below traps.80+    try {81+      this.getUint8Memory().set(shards, shardsPointer);8082 81-    const shardSize = shardsLength / (dataShards + parityShards);82-    const result = exports.encode(83-      shardsPointer,84-      shardsLength,85-      dataShards,86-      parityShards,87-    );88-    if (result === ReedSolomonErasure.RESULT_OK) {89-      shards.set(90-        this.getUint8Memory().subarray(91-          shardsPointer + shardSize * dataShards,92-          shardsPointer + shardsLength,93-        ),94-        shardSize * dataShards,83+      const shardSize = shardsLength / (dataShards + parityShards);84+      const result = exports.encode(85+        shardsPointer,86+        shardsLength,87+        dataShards,88+        parityShards,9589       );90+      if (result === ReedSolomonErasure.RESULT_OK) {91+        shards.set(92+          this.getUint8Memory().subarray(93+            shardsPointer + shardSize * dataShards,94+            shardsPointer + shardsLength,95+          ),96+          shardSize * dataShards,97+        );98+      }99+      return result;100+    } finally {101+      exports.__wbindgen_free(shardsPointer, shardsLength);96102     }97-    exports.__wbindgen_free(shardsPointer, shardsLength);98-    return result;99103   }100104 101105   reconstruct(@@ -107,38 +111,43 @@107111     const exports = this.exports;108112     const shardsLength = shards.length;109113     const shardsPointer = exports.__wbindgen_malloc(shardsLength);110-    this.getUint8Memory().set(shards, shardsPointer);111114 112115     const shardsAvailableBytes = new Uint8Array(shardsAvailable.length);113116     shardsAvailableBytes.set(shardsAvailable.map((value) => (value ? 1 : 0)));114117     const shardsAvailablePointer = exports.__wbindgen_malloc(115118       shardsAvailableBytes.length,116119     );117-    this.getUint8Memory().set(shardsAvailableBytes, shardsAvailablePointer);120+    // PCH-01-026 #8: both allocations are freed in finally so a trap between the121+    // malloc and the call below cannot leak either pointer.122+    try {123+      this.getUint8Memory().set(shards, shardsPointer);124+      this.getUint8Memory().set(shardsAvailableBytes, shardsAvailablePointer);118125 119-    const shardSize = shardsLength / (dataShards + parityShards);120-    const result = exports.reconstruct(121-      shardsPointer,122-      shardsLength,123-      dataShards,124-      parityShards,125-      shardsAvailablePointer,126-      shardsAvailableBytes.length,127-    );128-    if (result === ReedSolomonErasure.RESULT_OK) {129-      shards.set(130-        this.getUint8Memory().subarray(131-          shardsPointer,132-          shardsPointer + shardSize * dataShards,133-        ),126+      const shardSize = shardsLength / (dataShards + parityShards);127+      const result = exports.reconstruct(128+        shardsPointer,129+        shardsLength,130+        dataShards,131+        parityShards,132+        shardsAvailablePointer,133+        shardsAvailableBytes.length,134134       );135+      if (result === ReedSolomonErasure.RESULT_OK) {136+        shards.set(137+          this.getUint8Memory().subarray(138+            shardsPointer,139+            shardsPointer + shardSize * dataShards,140+          ),141+        );142+      }143+      return result;144+    } finally {145+      exports.__wbindgen_free(shardsPointer, shardsLength);146+      exports.__wbindgen_free(147+        shardsAvailablePointer,148+        shardsAvailableBytes.length,149+      );135150     }136-    exports.__wbindgen_free(shardsPointer, shardsLength);137-    exports.__wbindgen_free(138-      shardsAvailablePointer,139-      shardsAvailableBytes.length,140-    );141-    return result;142151   }143152 144153   private getUint8Memory(): Uint8Array {

view file

src/popup/codex/restore.ts+18 −5
@@ -30,8 +30,8 @@3030 } from "../../shared/codex-types";3131 import { reconstructPayloadFromStripes, type StripeChunks } from "./generate";3232 import {33-  base64UrlToBytes,3433   sha256Hex,34+  tryBase64UrlToBytes,3535   verifyChunkIntegrity,3636 } from "./helpers";3737 @@ -105,6 +105,16 @@105105       return { accepted: false, reason: "parse_error" };106106     }107107 108+    // PCH-01-026 #2: decode `d` once, up front, so an out-of-alphabet base64109+    // payload becomes a parse_error rejection instead of a thrown exception out110+    // of add() (honouring the documented "never throws" contract).111+    // verifyChunkIntegrity also decodes `d`, so guarding here covers both that112+    // decode and the accept-path decode below, which reuses `decoded`.113+    const decoded = tryBase64UrlToBytes(envelope.d);114+    if (decoded === null) {115+      return { accepted: false, reason: "parse_error" };116+    }117+108118     if (!(await verifyChunkIntegrity(envelope))) {109119       return { accepted: false, reason: "bad_integrity" };110120     }@@ -155,16 +165,19 @@155165       return { accepted: false, reason: "parse_error" };156166     }157167 158-    if (stripeState.chunks.has(envelope.i)) {159-      return { accepted: false, reason: "duplicate" };160-    }168+    // PCH-01-026 #7: validate the index range BEFORE the duplicate lookup. An169+    // out-of-range index is malformed, not a duplicate — classifying it by map170+    // membership would mis-report it as "duplicate".161171     if (envelope.i < 0 || envelope.i >= envelope.t) {162172       return { accepted: false, reason: "parse_error" };163173     }174+    if (stripeState.chunks.has(envelope.i)) {175+      return { accepted: false, reason: "duplicate" };176+    }164177 165178     stripeState.chunks.set(envelope.i, {166179       envelope,167-      bytes: base64UrlToBytes(envelope.d),180+      bytes: decoded, // PCH-01-026 #2: reuse the single successful decode168181     });169182     return { accepted: true };170183   }

view file

src/shared/chirp-sync-transport.ts+11 −5
@@ -58,7 +58,6 @@5858   const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);5959   const inbox: Uint8Array[] = [];6060   const readers: Array<(b: Uint8Array) => void> = [];61-  const errors: Error[] = [];6261   let closed = false;6362 6463   ws.binaryType = "arraybuffer";@@ -69,9 +68,12 @@6968       // A peer that sends one is misbehaving — tear the session down rather7069       // than letting the inbox grow toward OOM.7170       if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {72-        errors.push(new Error(73-          `sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes)`,74-        ));71+        // MV-3: oversized frame — tear the session down. close() routes through72+        // onclose, which rejects any parked receive(), so no error array is73+        // needed (PCH-01-026 #9).74+        console.warn(75+          `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,76+        );7577         try { ws.close(); } catch { /* ignore */ }7678         return;7779       }@@ -82,7 +84,11 @@8284     }8385   };8486   ws.onerror = () => {85-    errors.push(new Error("WebSocket error"));87+    // PCH-01-026 #9: surface the error and unblock any parked receive() by88+    // closing. Browsers usually follow error with close, but not always; an89+    // explicit close guarantees the handshake fails fast instead of hanging.90+    console.warn("[Peach] sync WebSocket error; closing transport");91+    try { ws.close(); } catch { /* ignore */ }8692   };8793   ws.onclose = () => {8894     closed = true;

view file

src/shared/import.test.ts+18 −0
@@ -78,6 +78,24 @@7878     expect(result.entries[0].totpPeriod).toBeUndefined();7979   });8080 81+  it("parse_csv_returns_total_items_matching_rows (PCH-01-026 #5)", async () => {82+    // 5 data rows; one has no password and is skipped.83+    const result = await parseImportSource(84+      [85+        "name,url,email,password,note,totp",86+        '"A","https://a.test","[email protected]","pw","",""',87+        '"B","https://b.test","[email protected]","pw","",""',88+        '"C","https://c.test","[email protected]","pw","",""',89+        '"D","https://d.test","[email protected]","pw","",""',90+        '"E","https://e.test","[email protected]","","",""',91+      ].join("\n")92+    );93+    expect(result.format).toBe("csv");94+    expect(result.entries).toHaveLength(4);95+    expect(result.totalItems).toBe(5);96+    expect(result.skippedItems).toBe(1);97+  });98+8199   it("preserves non-default TOTP params from an otpauth URI (PCH-01-019)", async () => {82100     const result = await parseImportSource(83101       [

view file

src/shared/import.ts+7 −7
@@ -53,11 +53,7 @@5353 5454   switch (format) {5555     case "csv": {56-      const entries = parseCSV(raw);57-      const totalItems = Papa.parse<Record<string, string>>(raw, {58-        header: true,59-        skipEmptyLines: true,60-      }).data.length;56+      const { entries, totalItems } = parseCSV(raw);6157       return {6258         format,6359         label: "CSV import",@@ -212,14 +208,16 @@212208   return entries;213209 }214210 215-function parseCSV(csvText: string): ImportableEntry[] {211+function parseCSV(csvText: string): { entries: ImportableEntry[]; totalItems: number } {216212   const result = Papa.parse<Record<string, string>>(csvText, {217213     header: true,218214     skipEmptyLines: true,219215     transformHeader: (header) => header.trim(),220216   });221217 222-  return result.data.reduce<ImportableEntry[]>((entries, row) => {218+  // PCH-01-026 #5: return totalItems from this single parse so parseImportSource219+  // doesn't re-run Papa.parse over the whole file just to count rows.220+  const entries = result.data.reduce<ImportableEntry[]>((entries, row) => {223221       const title = pickFirstField(row, [224222         "name",225223         "title",@@ -281,6 +279,8 @@281279       });282280       return entries;283281     }, []);282+283+  return { entries, totalItems: result.data.length };284284 }285285 286286 function parseBitwardenJSON(raw: string): {

view file