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
4 changes: 4 additions & 0 deletions apps/gittensory-miner-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
17 changes: 17 additions & 0 deletions apps/gittensory-miner-extension/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand All @@ -29,6 +45,7 @@ if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) {
parseWatchedRepos,
parseRankedCandidatesJson,
removeLegacyDiscoveryIndexUrl,
MAX_RANKED_CANDIDATES_JSON_BYTES,
};
}

Expand Down
80 changes: 80 additions & 0 deletions test/unit/miner-extension-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> = [];
const elements = {
"#settings": createFormMock(),
"#status": { textContent: "" },
"#watchedRepos": { value: "JSONbored/gittensory" },
"#rankedCandidatesJson": { value: "" },
};
const context: Record<string, unknown> = {
__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<string, unknown>) => {
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/);
Expand Down Expand Up @@ -258,6 +334,7 @@ describe("miner extension opportunity badge", () => {
const context: Record<string, unknown> = {
__GITTENSORY_MINER_EXTENSION_TEST__: true,
Date: { now: () => fakeNowMs },
TextEncoder,
document: { querySelector: (selector: string) => elements[selector as keyof typeof elements] ?? null },
chrome: {
storage: {
Expand Down Expand Up @@ -307,6 +384,7 @@ describe("miner extension opportunity badge", () => {
};
const context: Record<string, unknown> = {
__GITTENSORY_MINER_EXTENSION_TEST__: true,
TextEncoder,
document: { querySelector: (selector: string) => elements[selector as keyof typeof elements] ?? null },
chrome: {
storage: {
Expand Down Expand Up @@ -466,6 +544,7 @@ function loadBackgroundInternals({
function loadOptionsInternals() {
const context: Record<string, unknown> = {
__GITTENSORY_MINER_EXTENSION_TEST__: true,
TextEncoder,
document: { querySelector: () => null },
chrome: {
storage: {
Expand All @@ -482,5 +561,6 @@ function loadOptionsInternals() {
parseWatchedRepos: (text: string) => string[];
parseRankedCandidatesJson: (text: string) => unknown[];
removeLegacyDiscoveryIndexUrl: () => Promise<void>;
MAX_RANKED_CANDIDATES_JSON_BYTES: number;
};
}