ext: capture/downloads-api.ts — downloads.onCreated safety net

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 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
This commit is contained in:
2026-09-10 15:39:40 +04:00
co-authored by Claude Sonnet 5
parent 23407974d2
commit 175185bbfc
6 changed files with 491 additions and 17 deletions
@@ -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> = {}): 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<typeof vi.fn>;
cancel: ReturnType<typeof vi.fn>;
erase: ReturnType<typeof vi.fn>;
seen: CaptureOfferParams[];
}
function makeNet(over: Partial<DownloadsSafetyNetDeps> = {}): Harness {
const seen: CaptureOfferParams[] = [];
const offer =
(over.offer as ReturnType<typeof vi.fn> | undefined) ??
vi.fn(async (p: CaptureOfferParams): Promise<CaptureOfferResult> => {
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<typeof vi.fn>, 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();
});
});