diff --git a/apps/gittensory-miner-extension/README.md b/apps/gittensory-miner-extension/README.md index 19c548d416..5071648260 100644 --- a/apps/gittensory-miner-extension/README.md +++ b/apps/gittensory-miner-extension/README.md @@ -23,3 +23,7 @@ every save, and looks up the current issue there. When no ranked signal is cache degrades gracefully by staying hidden. The badge itself shows a "last synced" relative-time label (mirroring ORB's shared `RefreshMeta` component's thresholds) so a contributor can tell how stale the pasted data is; the label is omitted entirely for a cache saved before this field existed. + +The extension does not request the `unlimitedStorage` permission, so a paste is rejected with a clear error before +being parsed or saved once it exceeds a conservative size bound well under `chrome.storage.local`'s default 10 MiB +quota, instead of silently failing to save or leaving storage partially written. diff --git a/apps/gittensory-miner-extension/options.js b/apps/gittensory-miner-extension/options.js index 391d54a022..7480239264 100644 --- a/apps/gittensory-miner-extension/options.js +++ b/apps/gittensory-miner-extension/options.js @@ -5,9 +5,25 @@ function parseWatchedRepos(text) { .filter(Boolean); } +// This extension does not request the "unlimitedStorage" permission, so chrome.storage.local is capped at its +// default 10 MiB (QUOTA_BYTES) quota shared across every key -- an unbounded paste can silently fail to save +// or leave storage in a partial state (#4863). Measured with TextEncoder against the actual serialized UTF-8 +// byte size, NOT the pasted text's UTF-16 .length -- a char count undercounts any multibyte content (e.g. a +// non-ASCII repo/issue title), so a payload that passes a char-based check could still exceed the real quota at +// chrome.storage.local.set, recreating the exact silent-failure bug this guard exists to prevent. TextEncoder is +// a standard Web API available in both the real extension runtime and this repo's node:vm test harness (once +// injected into the sandbox context). +const MAX_RANKED_CANDIDATES_JSON_BYTES = 8 * 1024 * 1024; + function parseRankedCandidatesJson(text) { const trimmed = String(text ?? "").trim(); if (!trimmed) return []; + const byteLength = new TextEncoder().encode(trimmed).length; + if (byteLength > MAX_RANKED_CANDIDATES_JSON_BYTES) { + throw new Error( + `Ranked candidates JSON is too large (${byteLength.toLocaleString()} bytes; limit ${MAX_RANKED_CANDIDATES_JSON_BYTES.toLocaleString()}). Paste a smaller discover-run export.`, + ); + } const parsed = JSON.parse(trimmed); if (!Array.isArray(parsed)) { throw new Error("Ranked candidates JSON must be an array."); @@ -29,6 +45,7 @@ if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { parseWatchedRepos, parseRankedCandidatesJson, removeLegacyDiscoveryIndexUrl, + MAX_RANKED_CANDIDATES_JSON_BYTES, }; } diff --git a/test/unit/miner-extension-content.test.ts b/test/unit/miner-extension-content.test.ts index 9899237d9e..91c87f90c8 100644 --- a/test/unit/miner-extension-content.test.ts +++ b/test/unit/miner-extension-content.test.ts @@ -148,6 +148,82 @@ describe("miner extension opportunity badge", () => { expect(() => internals.parseRankedCandidatesJson('{"not":"array"}')).toThrow(); }); + it("rejects a pasted ranked-candidates JSON payload over the storage-size bound with a clear error, before ever attempting to parse it (#4863)", () => { + const internals = loadOptionsInternals(); + const oversized = "not valid json but that must not matter".padEnd( + internals.MAX_RANKED_CANDIDATES_JSON_BYTES + 1, + "x", + ); + + expect(() => internals.parseRankedCandidatesJson(oversized)).toThrow(/too large/i); + // Invariant: the size check runs before JSON.parse, so an oversized-but-invalid payload fails on size, + // not on a JSON syntax error -- proven by using text that isn't valid JSON at all. + try { + internals.parseRankedCandidatesJson(oversized); + } catch (error) { + expect(String(error)).not.toMatch(/Unexpected token/i); + } + }); + + it("accepts a pasted ranked-candidates JSON payload exactly at the storage-size bound (#4863)", () => { + const internals = loadOptionsInternals(); + const padding = "x".repeat(internals.MAX_RANKED_CANDIDATES_JSON_BYTES - 4); + const atLimit = `["${padding}"]`; + expect(atLimit).toHaveLength(internals.MAX_RANKED_CANDIDATES_JSON_BYTES); + + expect(internals.parseRankedCandidatesJson(atLimit)).toEqual([padding]); + }); + + it("REGRESSION (gate-caught): measures real UTF-8 byte size, not UTF-16 character count, so multibyte content can't sneak past the bound (#4863)", () => { + const internals = loadOptionsInternals(); + // "é" is 1 UTF-16 code unit but 2 UTF-8 bytes -- a char-length check would see roughly half the real byte + // size and wrongly accept a payload that actually exceeds the quota once chrome.storage.local serializes it. + const halfLimitCharCount = Math.floor(internals.MAX_RANKED_CANDIDATES_JSON_BYTES / 2) + 10; + const padding = "é".repeat(halfLimitCharCount); + const payload = `["${padding}"]`; + + expect(payload.length).toBeLessThan(internals.MAX_RANKED_CANDIDATES_JSON_BYTES); + expect(() => internals.parseRankedCandidatesJson(payload)).toThrow(/too large/i); + }); + + it("regression: an oversized paste surfaces its error through the save flow and never reaches chrome.storage.local.set (#4863)", async () => { + const localSetCalls: Array> = []; + const elements = { + "#settings": createFormMock(), + "#status": { textContent: "" }, + "#watchedRepos": { value: "JSONbored/gittensory" }, + "#rankedCandidatesJson": { value: "" }, + }; + const context: Record = { + __GITTENSORY_MINER_EXTENSION_TEST__: true, + TextEncoder, + document: { querySelector: (selector: string) => elements[selector as keyof typeof elements] ?? null }, + chrome: { + storage: { + sync: { get: async () => ({ watchedRepos: [] }), set: async () => {}, remove: async () => {} }, + local: { + get: async () => ({ rankedCandidates: [] }), + set: async (value: Record) => { + localSetCalls.push(value); + }, + }, + }, + }, + window: { setTimeout: () => 0 }, + }; + context.globalThis = context; + const vmContext = createContext(context); + new Script(optionsScript).runInContext(vmContext); + await flushPromises(); + + const internals = vmContext.__gittensoryMinerOptionsInternals as { MAX_RANKED_CANDIDATES_JSON_BYTES: number }; + elements["#rankedCandidatesJson"].value = "x".repeat(internals.MAX_RANKED_CANDIDATES_JSON_BYTES + 1); + await elements["#settings"].dispatchSubmit(); + + expect(elements["#status"].textContent).toMatch(/too large/i); + expect(localSetCalls).toHaveLength(0); + }); + it("REGRESSION (dead-field removal): no discoveryIndexUrl config field remains in the UI or background reads", () => { expect(optionsHtml).not.toMatch(/discoveryIndexUrl/); expect(backgroundScript).not.toMatch(/discoveryIndexUrl/); @@ -258,6 +334,7 @@ describe("miner extension opportunity badge", () => { const context: Record = { __GITTENSORY_MINER_EXTENSION_TEST__: true, Date: { now: () => fakeNowMs }, + TextEncoder, document: { querySelector: (selector: string) => elements[selector as keyof typeof elements] ?? null }, chrome: { storage: { @@ -307,6 +384,7 @@ describe("miner extension opportunity badge", () => { }; const context: Record = { __GITTENSORY_MINER_EXTENSION_TEST__: true, + TextEncoder, document: { querySelector: (selector: string) => elements[selector as keyof typeof elements] ?? null }, chrome: { storage: { @@ -466,6 +544,7 @@ function loadBackgroundInternals({ function loadOptionsInternals() { const context: Record = { __GITTENSORY_MINER_EXTENSION_TEST__: true, + TextEncoder, document: { querySelector: () => null }, chrome: { storage: { @@ -482,5 +561,6 @@ function loadOptionsInternals() { parseWatchedRepos: (text: string) => string[]; parseRankedCandidatesJson: (text: string) => unknown[]; removeLegacyDiscoveryIndexUrl: () => Promise; + MAX_RANKED_CANDIDATES_JSON_BYTES: number; }; }