From 175185bbfc48f2a5a793e2f1c641cee0639af1ce Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:39:40 +0400 Subject: [PATCH] =?UTF-8?q?ext:=20capture/downloads-api.ts=20=E2=80=94=20d?= =?UTF-8?q?ownloads.onCreated=20safety=20net?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downloads that never reach the header hook (form POST results, service-worker responses, clicks Firefox routes straight to its downloader) surface here. If one looks like the daemon's, offer it FIRST and only cancel + erase Firefox's copy on {action:"take"} — a failed or slow offer can never leave the user with nothing. blob:/data: downloads are left to Firefox (the daemon can't fetch a blob URL). - offered-urls.ts: short-lived, bounded TTL set of URLs the header hook has already offered; the safety net checks it (via wasOffered) so nothing is double-handled. Hook gains an onOffered hook to populate it. - background/index.ts: both paths share one offer(), getCookies, rules mirror, and OfferedUrls instance. 13 new tests incl. fail-open (offer rejects -> 'error', ignore -> 'offer_declined', cancel() throwing after take still returns 'taken'). 101 tests green; web-ext lint clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk --- .../src/background/capture/downloads-api.ts | 186 ++++++++++++++++++ extension/src/background/capture/index.ts | 6 +- .../src/background/capture/offered-urls.ts | 51 +++++ extension/src/background/index.ts | 52 +++-- extension/tests/capture/downloads-api.test.ts | 174 ++++++++++++++++ extension/tests/capture/offered-urls.test.ts | 39 ++++ 6 files changed, 491 insertions(+), 17 deletions(-) create mode 100644 extension/src/background/capture/downloads-api.ts create mode 100644 extension/src/background/capture/offered-urls.ts create mode 100644 extension/tests/capture/downloads-api.test.ts create mode 100644 extension/tests/capture/offered-urls.test.ts diff --git a/extension/src/background/capture/downloads-api.ts b/extension/src/background/capture/downloads-api.ts new file mode 100644 index 0000000..68a8fc6 --- /dev/null +++ b/extension/src/background/capture/downloads-api.ts @@ -0,0 +1,186 @@ +// The downloads.onCreated safety net. +// +// Some downloads never reach the blocking header hook — a form POST's result, a +// service-worker-generated response, a click that Firefox routed straight to its own +// downloader. Those surface here. If one looks like something the daemon should own, +// offer it FIRST and only then cancel + erase Firefox's copy — so a failed or slow offer +// can never leave the user with no download at all. +// +// blob:/data: downloads are left to Firefox: their bytes exist only in the page, and the +// daemon cannot fetch a blob URL. + +import type { CaptureOfferParams, CaptureOfferResult, CaptureRules, Cookie } from '../../shared/protocol/index.js'; + +import { guessFilename, toWireCookies, type CookieRecord } from './index.js'; +import { filenameExtension, hostExcluded } from './rules.js'; + +export interface DownloadItem { + id: number; + url: string; + finalUrl?: string; + referrer?: string; + filename?: string; + mime?: string; + /** -1 when unknown. */ + fileSize?: number; + /** -1 when unknown. */ + totalBytes?: number; + state?: 'in_progress' | 'interrupted' | 'complete'; +} + +interface DownloadEvent void> { + addListener(listener: L): void; + removeListener(listener: L): void; +} + +export interface DownloadsApiLike { + onCreated: DownloadEvent<(item: DownloadItem) => void>; + cancel(id: number): Promise; + erase(query: { id: number }): Promise; +} + +export interface DownloadsSafetyNetDeps { + offer: (params: CaptureOfferParams, opts?: { timeoutMs?: number }) => Promise; + downloads: DownloadsApiLike; + getCookies: (url: string) => Promise; + getRules: () => CaptureRules; + /** True when the header hook already offered this URL. */ + wasOffered?: (url: string) => boolean; + onOffered?: (url: string) => void; + origin?: string; + budgetMs?: number; +} + +/** Why the safety net did or did not act on a created download — for the popup diagnostics. */ +export type SafetyNetOutcome = + | 'taken' // offered and the daemon took it; Firefox's copy was cancelled + erased + | 'offer_declined' // offered, daemon replied ignore + | 'error' // fail-open: something threw, Firefox keeps the download + | 'capture_disabled' + | 'blob_or_data' + | 'excluded_host' + | 'already_offered' + | 'already_settled' + | 'no_rule_matched'; + +type VetoOutcome = Exclude; + +const BUDGET_MS = 750; + +export class DownloadsSafetyNet { + private readonly budgetMs: number; + private bound?: (item: DownloadItem) => void; + private attachedTo?: DownloadsApiLike; + + constructor(private readonly deps: DownloadsSafetyNetDeps) { + this.budgetMs = deps.budgetMs ?? BUDGET_MS; + } + + attach(downloads: DownloadsApiLike): void { + if (this.attachedTo) throw new Error('DownloadsSafetyNet is already attached'); + this.attachedTo = downloads; + this.bound = (item) => void this.handle(item); + downloads.onCreated.addListener(this.bound); + } + + detach(): void { + if (this.attachedTo && this.bound) this.attachedTo.onCreated.removeListener(this.bound); + this.attachedTo = undefined; + this.bound = undefined; + } + + /** Never throws. Returns what it did, for diagnostics/tests. */ + async handle(item: DownloadItem): Promise { + try { + const url = effectiveUrl(item); + const decision = this.decide(item, url); + if (!decision.capture) return decision.reason; + + let cookies: Cookie[] = []; + try { + cookies = toWireCookies(await this.deps.getCookies(url)); + } catch { + cookies = []; + } + + const params: CaptureOfferParams = { + url, + method: 'GET', // onCreated does not expose the method, and a POST body can't be replayed + tabUrl: item.referrer ?? '', + cookies, + contentType: item.mime ?? null, + contentLength: sizeOf(item), + filename: basenameOf(item.filename) ?? guessFilename(url, undefined), + referrer: item.referrer ?? null, + origin: this.deps.origin ?? null, + }; + + this.deps.onOffered?.(url); + const result = await this.deps.offer(params, { timeoutMs: this.budgetMs }); + if (result.action !== 'take') return 'offer_declined'; + + // The daemon owns it now — retire Firefox's copy. Both best-effort. + try { + await this.deps.downloads.cancel(item.id); + } catch { + /* already complete or gone */ + } + try { + await this.deps.downloads.erase({ id: item.id }); + } catch { + /* nothing to erase */ + } + return 'taken'; + } catch { + return 'error'; // fail open: Firefox keeps the download + } + } + + private decide(item: DownloadItem, url: string): { capture: true } | { capture: false; reason: VetoOutcome } { + const veto = (reason: VetoOutcome) => ({ capture: false as const, reason }); + const rules = this.deps.getRules(); + if (!rules.enabled) return veto('capture_disabled'); + if (/^(blob:|data:)/i.test(url)) return veto('blob_or_data'); + if (item.state === 'complete' || item.state === 'interrupted') return veto('already_settled'); + if (this.deps.wasOffered?.(url)) return veto('already_offered'); + + const host = hostOf(url); + if (host && hostExcluded(host, rules.excludedHosts)) return veto('excluded_host'); + + const ext = filenameExtension(basenameOf(item.filename) ?? url); + if (ext && rules.monitoredExtensions.includes(ext)) return { capture: true }; + if (item.mime && rules.monitoredMimeTypes.includes(item.mime.toLowerCase())) return { capture: true }; + + const size = sizeOf(item); + if (size !== null && size > rules.minSizeBytes) return { capture: true }; + + return veto('no_rule_matched'); + } +} + +// --- helpers ---------------------------------------------------------------------- + +function effectiveUrl(item: DownloadItem): string { + return item.finalUrl && item.finalUrl.length > 0 ? item.finalUrl : item.url; +} + +function sizeOf(item: DownloadItem): number | null { + for (const v of [item.totalBytes, item.fileSize]) { + if (typeof v === 'number' && v > 0) return v; + } + return null; +} + +function basenameOf(filename: string | undefined): string | undefined { + if (!filename) return undefined; + const base = filename.split(/[\\/]/).pop(); + return base && base.length > 0 ? base : undefined; +} + +function hostOf(url: string): string { + try { + return new URL(url).hostname; + } catch { + return ''; + } +} diff --git a/extension/src/background/capture/index.ts b/extension/src/background/capture/index.ts index cb48757..c2ed05c 100644 --- a/extension/src/background/capture/index.ts +++ b/extension/src/background/capture/index.ts @@ -68,6 +68,9 @@ export interface CaptureHookDeps { getRules: () => CaptureRules; /** Whether the user held the bypass modifier for the click behind this request. */ bypassHeld?: (details: OnHeadersReceivedDetails) => boolean; + /** Called with the URL just before an offer is made, so the downloads.onCreated safety + * net can tell this request has already been handled here. */ + onOffered?: (url: string) => void; /** moz-extension://, for CaptureOfferParams.origin. */ origin?: string; budgetMs?: number; @@ -167,6 +170,7 @@ export class CaptureHook { requestId: details.requestId, }; + this.deps.onOffered?.(details.url); const result = await this.deps.offer(params, { timeoutMs: this.budgetMs }); return result.action === 'take' ? CANCEL : PROCEED; } catch { @@ -185,7 +189,7 @@ export class CaptureHook { // --- helpers ---------------------------------------------------------------------- -function toWireCookies(records: CookieRecord[]): Cookie[] { +export function toWireCookies(records: CookieRecord[]): Cookie[] { return records.map((c) => { const out: Cookie = { name: c.name, value: c.value }; if (c.domain !== undefined) out.domain = c.domain; diff --git a/extension/src/background/capture/offered-urls.ts b/extension/src/background/capture/offered-urls.ts new file mode 100644 index 0000000..7c4918a --- /dev/null +++ b/extension/src/background/capture/offered-urls.ts @@ -0,0 +1,51 @@ +// A short-lived record of URLs the header hook has already offered to the daemon, so the +// downloads.onCreated safety net does not offer the same thing a second time. +// +// Bounded and time-capped for the same reason the header stash is (capture/headers.ts): +// this sees a URL for every capture attempt and must never grow without bound. + +export interface OfferedUrlsOptions { + /** Entries older than this stop counting as "recently offered". Default 15 s. */ + ttlMs?: number; + /** Hard cap on tracked URLs; oldest evicted past it. Default 256. */ + maxEntries?: number; + now?: () => number; +} + +export class OfferedUrls { + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + /** url -> timestamp, insertion-ordered so the first key is the oldest. */ + private readonly seen = new Map(); + + constructor(opts: OfferedUrlsOptions = {}) { + this.ttlMs = opts.ttlMs ?? 15_000; + this.maxEntries = opts.maxEntries ?? 256; + this.now = opts.now ?? Date.now; + } + + mark(url: string): void { + this.seen.delete(url); + this.seen.set(url, this.now()); + while (this.seen.size > this.maxEntries) { + const oldest = this.seen.keys().next().value; + if (oldest === undefined) break; + this.seen.delete(oldest); + } + } + + has(url: string): boolean { + const at = this.seen.get(url); + if (at === undefined) return false; + if (this.now() - at > this.ttlMs) { + this.seen.delete(url); + return false; + } + return true; + } + + get size(): number { + return this.seen.size; + } +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index ad2be03..c524bc4 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -1,34 +1,53 @@ // Background event-page entry. // -// Brings the transport up, mirrors the daemon's capture rules, and wires the two capture -// paths: the blocking onHeadersReceived hook and (next) the downloads.onCreated safety -// net. The popup relay and context menus attach here in later steps of the build order. +// Brings the transport up, mirrors the daemon's capture rules, and wires both capture +// paths: the blocking onHeadersReceived hook and the downloads.onCreated safety net. The +// popup relay and context menus attach here in later steps of the build order. -import { CaptureHook } from './capture/index.js'; -import { HeaderStash } from './capture/headers.js'; -import type { WebRequestLike } from './capture/headers.js'; -import type { HeadersReceivedWebRequest } from './capture/index.js'; +import { DownloadsSafetyNet, type DownloadsApiLike } from './capture/downloads-api.js'; +import { HeaderStash, type WebRequestLike } from './capture/headers.js'; +import { CaptureHook, type HeadersReceivedWebRequest } from './capture/index.js'; +import { OfferedUrls } from './capture/offered-urls.js'; import { DEFAULT_CAPTURE_RULES } from './capture/rules.js'; import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js'; -import type { CaptureRules } from '../shared/protocol/index.js'; +import type { CaptureOfferParams, CaptureRules } from '../shared/protocol/index.js'; let transport: VeloxTransport | undefined; let rules: CaptureRules = DEFAULT_CAPTURE_RULES; -const stash = new HeaderStash(); -const hook = new CaptureHook({ - offer: (params, opts) => mustTransport().call('capture.offer', params, opts), - stash, - getCookies: (url) => browser.cookies.getAll({ url }), - getRules: () => rules, - origin: new URL(browser.runtime.getURL('/')).origin, -}); +const EXTENSION_ORIGIN = new URL(browser.runtime.getURL('/')).origin; function mustTransport(): VeloxTransport { if (!transport) throw new Error('transport not ready'); return transport; } +const offer = (params: CaptureOfferParams, opts?: { timeoutMs?: number }) => + mustTransport().call('capture.offer', params, opts); +const getCookies = (url: string) => browser.cookies.getAll({ url }); + +const offered = new OfferedUrls(); +const stash = new HeaderStash(); + +const hook = new CaptureHook({ + offer, + stash, + getCookies, + getRules: () => rules, + onOffered: (url) => offered.mark(url), + origin: EXTENSION_ORIGIN, +}); + +const safetyNet = new DownloadsSafetyNet({ + offer, + downloads: browser.downloads as unknown as DownloadsApiLike, + getCookies, + getRules: () => rules, + wasOffered: (url) => offered.has(url), + onOffered: (url) => offered.mark(url), + origin: EXTENSION_ORIGIN, +}); + async function refreshRules(): Promise { try { rules = await mustTransport().call('capture.getRules', {}); @@ -46,6 +65,7 @@ function onTransportState(status: TransportStatus): void { async function start(): Promise { stash.attach(browser.webRequest as unknown as WebRequestLike); hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest); + safetyNet.attach(browser.downloads as unknown as DownloadsApiLike); transport = await createTransport(); transport.onStateChange(onTransportState); diff --git a/extension/tests/capture/downloads-api.test.ts b/extension/tests/capture/downloads-api.test.ts new file mode 100644 index 0000000..5e94a6d --- /dev/null +++ b/extension/tests/capture/downloads-api.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { CaptureOfferParams, CaptureOfferResult, CaptureRules } from '../../src/shared/protocol/index.js'; +import { + DownloadsSafetyNet, + type DownloadItem, + type DownloadsApiLike, + type DownloadsSafetyNetDeps, +} from '../../src/background/capture/downloads-api.js'; + +const RULES: CaptureRules = { + enabled: true, + monitoredExtensions: ['zip', 'iso'], + monitoredMimeTypes: ['application/octet-stream'], + minSizeBytes: 1024 * 1024, + excludedHosts: ['mail.example.com'], + rulesVersion: 1, +}; + +function item(over: Partial = {}): DownloadItem { + return { + id: 5, + url: 'https://cdn.example.com/pkg/tool.zip', + finalUrl: 'https://cdn.example.com/pkg/tool.zip', + referrer: 'https://example.com/get', + filename: '/home/u/Downloads/tool.zip', + mime: 'application/zip', + fileSize: -1, + totalBytes: 50 * 1024 * 1024, + state: 'in_progress', + ...over, + }; +} + +interface Harness { + net: DownloadsSafetyNet; + offer: ReturnType; + cancel: ReturnType; + erase: ReturnType; + seen: CaptureOfferParams[]; +} + +function makeNet(over: Partial = {}): Harness { + const seen: CaptureOfferParams[] = []; + const offer = + (over.offer as ReturnType | undefined) ?? + vi.fn(async (p: CaptureOfferParams): Promise => { + seen.push(p); + return { action: 'take', taskId: 't1' }; + }); + const cancel = vi.fn(async () => undefined); + const erase = vi.fn(async () => [5]); + const downloads = { onCreated: { addListener: vi.fn(), removeListener: vi.fn() }, cancel, erase } as unknown as DownloadsApiLike; + const deps: DownloadsSafetyNetDeps = { + offer: offer as unknown as DownloadsSafetyNetDeps['offer'], + downloads, + getCookies: vi.fn(async () => [{ name: 'sid', value: 'x' }]) as unknown as DownloadsSafetyNetDeps['getCookies'], + getRules: () => RULES, + origin: 'moz-extension://uuid', + ...over, + }; + return { net: new DownloadsSafetyNet(deps), offer: offer as ReturnType, cancel, erase, seen }; +} + +describe('DownloadsSafetyNet.decide', () => { + it('offers a monitored-extension download and, on take, cancels + erases it', async () => { + const { net, cancel, erase, seen } = makeNet(); + await expect(net.handle(item({ mime: 'application/zip' }))).resolves.toBe('taken'); + expect(seen[0]).toMatchObject({ + url: 'https://cdn.example.com/pkg/tool.zip', + method: 'GET', + filename: 'tool.zip', + contentLength: 50 * 1024 * 1024, + referrer: 'https://example.com/get', + origin: 'moz-extension://uuid', + }); + expect(cancel).toHaveBeenCalledWith(5); + expect(erase).toHaveBeenCalledWith({ id: 5 }); + }); + + it('offers on the size threshold alone', async () => { + const { net } = makeNet(); + await expect( + net.handle(item({ filename: 'blob.bin', url: 'https://cdn.example.com/x', finalUrl: '', mime: 'application/x-thing', totalBytes: 9 * 1024 * 1024 })), + ).resolves.toBe('taken'); + }); + + it('leaves blob: downloads to Firefox', async () => { + const { net, offer } = makeNet(); + await expect(net.handle(item({ url: 'blob:https://example.com/abc', finalUrl: '' }))).resolves.toBe('blob_or_data'); + expect(offer).not.toHaveBeenCalled(); + }); + + it('skips an excluded host', async () => { + const { net } = makeNet(); + await expect( + net.handle(item({ url: 'https://mail.example.com/a.zip', finalUrl: 'https://mail.example.com/a.zip' })), + ).resolves.toBe('excluded_host'); + }); + + it('skips a download that already completed or was interrupted', async () => { + const { net } = makeNet(); + await expect(net.handle(item({ state: 'complete' }))).resolves.toBe('already_settled'); + await expect(net.handle(item({ state: 'interrupted' }))).resolves.toBe('already_settled'); + }); + + it('skips a URL the header hook already offered', async () => { + const { net, offer } = makeNet({ wasOffered: (u) => u === 'https://cdn.example.com/pkg/tool.zip' }); + await expect(net.handle(item())).resolves.toBe('already_offered'); + expect(offer).not.toHaveBeenCalled(); + }); + + it('skips when capture is disabled', async () => { + const { net } = makeNet({ getRules: () => ({ ...RULES, enabled: false }) }); + await expect(net.handle(item())).resolves.toBe('capture_disabled'); + }); + + it('skips a small file that matches no rule', async () => { + const { net } = makeNet(); + await expect( + net.handle(item({ filename: 'note.txt', url: 'https://cdn.example.com/note.txt', finalUrl: '', mime: 'text/plain', totalBytes: 200 })), + ).resolves.toBe('no_rule_matched'); + }); +}); + +describe('DownloadsSafetyNet — fail-open', () => { + it('offer() rejects → the Firefox download is left completely alone', async () => { + const { net, cancel, erase } = makeNet({ + offer: vi.fn().mockRejectedValue(new Error('daemon down')) as unknown as DownloadsSafetyNetDeps['offer'], + }); + await expect(net.handle(item())).resolves.toBe('error'); + expect(cancel).not.toHaveBeenCalled(); + expect(erase).not.toHaveBeenCalled(); + }); + + it('offer() → ignore leaves the download alone', async () => { + const { net, cancel } = makeNet({ + offer: vi.fn(async () => ({ action: 'ignore', reason: 'below_min_size' })) as unknown as DownloadsSafetyNetDeps['offer'], + }); + await expect(net.handle(item())).resolves.toBe('offer_declined'); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('cancel() throwing after a take does not throw out of handle()', async () => { + const { net, erase } = makeNet(); + (net as unknown as { deps: DownloadsSafetyNetDeps }).deps.downloads.cancel = vi + .fn() + .mockRejectedValue(new Error('already complete')); + await expect(net.handle(item())).resolves.toBe('taken'); + expect(erase).toHaveBeenCalled(); // still tries to erase + }); + + it('still offers with an empty cookie list when cookie collection fails', async () => { + const { net, seen } = makeNet({ + getCookies: vi.fn().mockRejectedValue(new Error('no perm')) as unknown as DownloadsSafetyNetDeps['getCookies'], + }); + await expect(net.handle(item())).resolves.toBe('taken'); + expect(seen[0]!.cookies).toEqual([]); + }); +}); + +describe('DownloadsSafetyNet.attach', () => { + it('registers and unregisters an onCreated listener', () => { + const add = vi.fn(); + const remove = vi.fn(); + const downloads = { onCreated: { addListener: add, removeListener: remove }, cancel: vi.fn(), erase: vi.fn() } as unknown as DownloadsApiLike; + const { net } = makeNet(); + net.attach(downloads); + expect(add).toHaveBeenCalledOnce(); + expect(() => net.attach(downloads)).toThrow(/already attached/); + net.detach(); + expect(remove).toHaveBeenCalledOnce(); + }); +}); diff --git a/extension/tests/capture/offered-urls.test.ts b/extension/tests/capture/offered-urls.test.ts new file mode 100644 index 0000000..dcecf30 --- /dev/null +++ b/extension/tests/capture/offered-urls.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { OfferedUrls } from '../../src/background/capture/offered-urls.js'; + +describe('OfferedUrls', () => { + it('remembers a URL for the TTL, then forgets it', () => { + let now = 0; + const o = new OfferedUrls({ ttlMs: 1000, now: () => now }); + o.mark('https://x/a.zip'); + expect(o.has('https://x/a.zip')).toBe(true); + now = 1001; + expect(o.has('https://x/a.zip')).toBe(false); + expect(o.size).toBe(0); + }); + + it('re-mark refreshes the TTL', () => { + let now = 0; + const o = new OfferedUrls({ ttlMs: 1000, now: () => now }); + o.mark('u'); + now = 900; + o.mark('u'); + now = 1800; // 900 since the last mark + expect(o.has('u')).toBe(true); + }); + + it('evicts the oldest past the size cap', () => { + const o = new OfferedUrls({ maxEntries: 2, ttlMs: 10_000, now: () => 0 }); + o.mark('a'); + o.mark('b'); + o.mark('c'); + expect(o.has('a')).toBe(false); + expect(o.has('b')).toBe(true); + expect(o.has('c')).toBe(true); + }); + + it('is false for a URL it has never seen', () => { + expect(new OfferedUrls().has('nope')).toBe(false); + }); +});