From 6340779aa64318cad636e8bb0005704cf317aa5f Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Sat, 18 Jul 2026 06:12:31 +1000 Subject: [PATCH] fix(miner-extension): guard the live-fetch sync path against the storage quota syncRankedCandidatesFromMinerUi wrote fetched ranked candidates to chrome.storage.local with no size check, unlike options.js's manual-paste flow which measures the real serialized UTF-8 byte size against the shared 10 MiB QUOTA_BYTES limit (#4863). A large live-fetch result could silently write a partial payload past the quota via this path. Apply the same TextEncoder byte-size guard before the storage write, returning the function's existing typed { ok: false, error, minerUiUrl } shape on an over-quota result rather than writing a partial payload. Closes #7006 --- apps/loopover-miner-extension/background.js | 14 +++++++++++++ .../test/background.test.ts | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/apps/loopover-miner-extension/background.js b/apps/loopover-miner-extension/background.js index 307ea7c432..55afdb045f 100644 --- a/apps/loopover-miner-extension/background.js +++ b/apps/loopover-miner-extension/background.js @@ -11,6 +11,9 @@ const SYNC_RANKED_CANDIDATES_MESSAGE = "loopover-miner:sync-ranked-candidates"; // (miner-ui running but unresponsive) should fail fast and let the next 10-minute alarm retry, following the // timeout pattern established in review-enrichment/src/external-fetch.ts. const RANKED_CANDIDATES_FETCH_TIMEOUT_MS = 3000; +// Mirrors options.js's manual-paste guard (#4863): the chrome.storage.local 10 MiB QUOTA_BYTES is shared +// across every key, so a live-fetched payload gets the same 8 MiB ceiling the paste flow already enforces. +const MAX_RANKED_CANDIDATES_JSON_BYTES = 8 * 1024 * 1024; chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (!message || typeof message.type !== "string") return false; @@ -123,6 +126,17 @@ async function syncRankedCandidatesFromMinerUi() { if (!candidates) { return { ok: false, error: "miner UI returned an unexpected payload shape", minerUiUrl }; } + // Same byte-size guard options.js's manual-paste flow enforces (#4863, ported here for #7006): measure the + // real serialized UTF-8 size (not JS string .length) against the shared 10 MiB QUOTA_BYTES limit, so a + // live fetch can't silently write a partial payload past the quota the way an unbounded paste could. + const byteLength = new TextEncoder().encode(JSON.stringify(candidates)).length; + if (byteLength > MAX_RANKED_CANDIDATES_JSON_BYTES) { + return { + ok: false, + error: `ranked candidates payload is too large (${byteLength.toLocaleString()} bytes; limit ${MAX_RANKED_CANDIDATES_JSON_BYTES.toLocaleString()})`, + minerUiUrl, + }; + } const savedAt = Date.now(); await chrome.storage.local.set({ rankedCandidates: candidates, rankedCandidatesSavedAt: savedAt }); return { ok: true, count: candidates.length, savedAt, minerUiUrl }; diff --git a/apps/loopover-miner-extension/test/background.test.ts b/apps/loopover-miner-extension/test/background.test.ts index 0ca224e5f9..812ae2b7e6 100644 --- a/apps/loopover-miner-extension/test/background.test.ts +++ b/apps/loopover-miner-extension/test/background.test.ts @@ -120,6 +120,27 @@ describe("background service worker", () => { expect(failed).toMatchObject({ ok: false, error: "connection refused" }); }); + it("REGRESSION (#7006): rejects an over-quota live-fetch payload instead of writing a partial one", async () => { + // Build a candidates array whose serialized JSON exceeds the 8 MiB byte-size ceiling options.js enforces, + // so the live-fetch path must fail closed rather than silently write past the shared storage quota. + const filler = "x".repeat(1024); + const candidates = Array.from({ length: 9 * 1024 }, (_, i) => ({ repoFullName: `acme/${i}`, note: filler })); + const oversized = await loadExtensionModules({ fetchImpl: jsonFetch(200, { candidates }) }); + const result = await oversized.backgroundInternals.syncRankedCandidatesFromMinerUi(); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/too large/); + // The write is never attempted -- storage keeps whatever it already had, matching every other failure branch. + expect(oversized.localSetCalls).toHaveLength(0); + }); + + it("REGRESSION (#7006): a payload within the quota still writes normally", async () => { + const candidates = [{ repoFullName: "acme/widgets", issueNumber: 1, rankScore: 0.8 }]; + const withinQuota = await loadExtensionModules({ fetchImpl: jsonFetch(200, { candidates }) }); + const result = await withinQuota.backgroundInternals.syncRankedCandidatesFromMinerUi(); + expect(result.ok).toBe(true); + expect(withinQuota.localSetCalls).toHaveLength(1); + }); + it("REGRESSION (#7007): bounds the miner-ui fetch with a 3s AbortSignal timeout", async () => { const timeoutSpy = vi.spyOn(AbortSignal, "timeout"); const { backgroundInternals } = await loadExtensionModules({