Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/loopover-miner-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down
21 changes: 21 additions & 0 deletions apps/loopover-miner-extension/test/background.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading