diff --git a/extension/manifest.json b/extension/manifest.json index 4d165e7..9f9097a 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -30,5 +30,12 @@ "nativeMessaging" ], - "host_permissions": [""] + "host_permissions": [""], + + "commands": { + "velox-grab-current-tab": { + "suggested_key": { "default": "Ctrl+Shift+U" }, + "description": "Send the current tab's URL to Velox" + } + } } 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 new file mode 100644 index 0000000..c2ed05c --- /dev/null +++ b/extension/src/background/capture/index.ts @@ -0,0 +1,232 @@ +// The blocking onHeadersReceived hook. +// +// For a response that shouldCapture() likes: gather cookies, offer it to the daemon with +// a hard 750 ms budget, and cancel the browser's own download ONLY on an explicit +// {action:"take"}. Everything else — shouldCapture says no, the daemon is down / slow / +// erroring, cookies fail, anything throws — resolves to {} and Firefox downloads +// normally. Fail-open is not a fallback path here; it is the default every branch returns +// to. tests/capture/hook.test.ts pins that. + +import type { + CaptureOfferParams, + CaptureOfferResult, + CaptureRules, + Cookie, +} from '../../shared/protocol/index.js'; + +import { normalizeHeaders } from './headers.js'; +import type { HeaderStash } from './headers.js'; +import { filenameExtension, shouldCapture, type CaptureCandidate, type ResourceType } from './rules.js'; + +const BUDGET_MS = 750; // docs/05 §2 and METHODS['capture.offer'].deadlineMs +const PROCEED: BlockingResponse = {}; +const CANCEL: BlockingResponse = { cancel: true }; + +export interface BlockingResponse { + cancel?: boolean; +} + +export interface OnHeadersReceivedDetails { + requestId: string; + url: string; + method: string; + type: ResourceType; + statusCode: number; + tabId: number; + responseHeaders?: { name: string; value?: string }[]; + documentUrl?: string | null; + originUrl?: string | null; +} + +interface WebRequestEvent void> { + addListener(listener: L, filter: { urls: string[] }, extraInfoSpec?: string[]): void; + removeListener(listener: L): void; +} + +export interface HeadersReceivedWebRequest { + onHeadersReceived: WebRequestEvent< + (d: OnHeadersReceivedDetails) => BlockingResponse | Promise + >; +} + +/** A browser cookie, as `browser.cookies.getAll` returns it (superset of the wire type). */ +export interface CookieRecord { + name: string; + value: string; + domain?: string; + path?: string; + secure?: boolean; + httpOnly?: boolean; +} + +export interface CaptureHookDeps { + /** Usually `(p, o) => transport.call('capture.offer', p, o)`. */ + offer: (params: CaptureOfferParams, opts?: { timeoutMs?: number }) => Promise; + stash: Pick; + getCookies: (url: string) => Promise; + /** A synchronous snapshot of the daemon's capture policy (mirrored via capture.getRules). */ + 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; +} + +const ALL_URLS = ''; + +export class CaptureHook { + private readonly budgetMs: number; + private bound?: (d: OnHeadersReceivedDetails) => Promise; + private attachedTo?: HeadersReceivedWebRequest; + + constructor(private readonly deps: CaptureHookDeps) { + this.budgetMs = deps.budgetMs ?? BUDGET_MS; + } + + /** The listener. Always resolves within the budget to {} or {cancel:true}; never rejects. */ + handle = (details: OnHeadersReceivedDetails): Promise => { + return this.withinBudget(this.decide(details)); + }; + + attach(webRequest: HeadersReceivedWebRequest): void { + if (this.attachedTo) throw new Error('CaptureHook is already attached'); + this.attachedTo = webRequest; + this.bound = this.handle; + webRequest.onHeadersReceived.addListener(this.bound, { urls: [ALL_URLS] }, [ + 'blocking', + 'responseHeaders', + ]); + } + + detach(): void { + if (this.attachedTo && this.bound) { + this.attachedTo.onHeadersReceived.removeListener(this.bound); + } + this.attachedTo = undefined; + this.bound = undefined; + } + + private withinBudget(work: Promise): Promise { + return new Promise((resolve) => { + let settled = false; + const done = (value: BlockingResponse): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const timer = setTimeout(() => done(PROCEED), this.budgetMs); + (timer as { unref?: () => void }).unref?.(); + work.then(done, () => done(PROCEED)); + }); + } + + private async decide(details: OnHeadersReceivedDetails): Promise { + try { + const rules = this.deps.getRules(); + const responseHeaders = normalizeHeaders(details.responseHeaders); + const stashed = this.stashPeek(details.requestId); + + const candidate: CaptureCandidate = { + url: details.url, + method: details.method, + statusCode: details.statusCode, + type: details.type, + responseHeaders, + requestHeaders: stashed?.headers ?? {}, + documentUrl: details.documentUrl ?? details.originUrl ?? null, + bypassHeld: this.deps.bypassHeld?.(details) ?? false, + }; + + if (!shouldCapture(candidate, rules).capture) return PROCEED; + + // We are acting on this request now — take the stash entry. + const taken = this.deps.stash.take(details.requestId); + const requestHeaders = taken?.headers ?? candidate.requestHeaders; + + let cookies: Cookie[] = []; + try { + cookies = toWireCookies(await this.deps.getCookies(details.url)); + } catch { + cookies = []; // best-effort; still worth offering + } + + const params: CaptureOfferParams = { + url: details.url, + method: candidate.method.toUpperCase() === 'POST' ? 'POST' : 'GET', + tabUrl: candidate.documentUrl ?? '', + headers: requestHeaders, + cookies, + contentType: responseHeaders['content-type'] ?? null, + contentLength: intOrNull(responseHeaders['content-length']), + contentDisposition: responseHeaders['content-disposition'] ?? null, + filename: guessFilename(details.url, responseHeaders['content-disposition']), + referrer: requestHeaders['referer'] ?? null, + origin: this.deps.origin ?? null, + 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 { + return PROCEED; // fail open on anything at all + } + } + + private stashPeek(requestId: string) { + try { + return this.deps.stash.peek(requestId); + } catch { + return undefined; + } + } +} + +// --- helpers ---------------------------------------------------------------------- + +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; + if (c.path !== undefined) out.path = c.path; + if (c.secure !== undefined) out.secure = c.secure; + if (c.httpOnly !== undefined) out.httpOnly = c.httpOnly; + return out; + }); +} + +function intOrNull(value: string | undefined): number | null { + if (value === undefined) return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +/** RFC 6266 filename, falling back to the URL's last path segment. */ +export function guessFilename(url: string, disposition: string | undefined): string | null { + if (disposition) { + const star = /filename\*\s*=\s*(?:[^']*'[^']*')?([^;]+)/i.exec(disposition); + if (star?.[1]) { + try { + return decodeURIComponent(star[1].trim().replace(/^"|"$/g, '')) || null; + } catch { + /* fall through */ + } + } + const plain = /filename\s*=\s*"?([^";]+)"?/i.exec(disposition); + if (plain?.[1]) return plain[1].trim() || null; + } + try { + const path = new URL(url).pathname; + const base = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1)); + if (base) return base; + } catch { + /* not a parseable URL */ + } + const ext = filenameExtension(url); + return ext ? `download.${ext}` : null; +} 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/capture/rules.ts b/extension/src/background/capture/rules.ts index 339e091..96b7bef 100644 --- a/extension/src/background/capture/rules.ts +++ b/extension/src/background/capture/rules.ts @@ -63,6 +63,19 @@ export interface CaptureDecision { reason: CaptureReason; } +/** + * What shouldCapture() runs against before the first capture.getRules has been mirrored + * from the daemon. `enabled: false` — we intercept nothing until we know the real policy. + */ +export const DEFAULT_CAPTURE_RULES: CaptureRules = { + enabled: false, + monitoredExtensions: [], + monitoredMimeTypes: [], + minSizeBytes: 1024 * 1024, + excludedHosts: [], + rulesVersion: 0, +}; + const RENDERABLE_TYPES = new Set([ 'text/html', 'application/xhtml+xml', diff --git a/extension/src/background/context-menus.ts b/extension/src/background/context-menus.ts new file mode 100644 index 0000000..c9d9d73 --- /dev/null +++ b/extension/src/background/context-menus.ts @@ -0,0 +1,123 @@ +// Right-click and keyboard entry points. +// +// docs/05 §3: "Download with Velox" on a link or a media element hands that one URL to +// the daemon; a configurable command grabs the active tab's URL the same way. The +// page/selection "Download all links…" item waits on the content-script link harvester +// (build-order step 7) and is added there. +// +// These are explicit user requests, so they go straight to download.add — no +// shouldCapture, no fail-open dance. + +import type { DownloadSpec } from '../shared/protocol/index.js'; + +export const LINK_MENU_ID = 'velox-download-link'; +export const MEDIA_MENU_ID = 'velox-download-media'; +export const GRAB_TAB_COMMAND = 'velox-grab-current-tab'; + +interface OnClickedInfo { + menuItemId: string | number; + linkUrl?: string; + srcUrl?: string; + pageUrl?: string; +} + +interface Tab { + url?: string; + title?: string; +} + +interface Listenable { + addListener(cb: (...args: A) => void): void; + removeListener(cb: (...args: A) => void): void; +} + +export interface MenusLike { + create(props: { + id: string; + title: string; + contexts: string[]; + }): void; + removeAll(): Promise | void; + onClicked: Listenable<[OnClickedInfo, Tab | undefined]>; +} + +export interface CommandsLike { + onCommand: Listenable<[string, Tab | undefined]>; +} + +export interface TabsLike { + query(q: { active: true; currentWindow: true }): Promise; +} + +export interface ContextMenusDeps { + addDownload: (spec: DownloadSpec) => Promise; + menus: MenusLike; + tabs: TabsLike; + commands?: CommandsLike; + /** Surface a failure to the user (a notification, usually). Optional. */ + onError?: (message: string) => void; +} + +export class ContextMenus { + private onClicked?: (info: OnClickedInfo, tab: Tab | undefined) => void; + private onCommand?: (name: string, tab: Tab | undefined) => void; + + constructor(private readonly deps: ContextMenusDeps) {} + + async register(): Promise { + await this.deps.menus.removeAll(); + this.deps.menus.create({ id: LINK_MENU_ID, title: 'Download with Velox', contexts: ['link'] }); + this.deps.menus.create({ + id: MEDIA_MENU_ID, + title: 'Download with Velox', + contexts: ['image', 'video', 'audio'], + }); + + this.onClicked = (info, tab) => void this.handleClick(info, tab); + this.deps.menus.onClicked.addListener(this.onClicked); + + if (this.deps.commands) { + this.onCommand = (name, tab) => void this.handleCommand(name, tab); + this.deps.commands.onCommand.addListener(this.onCommand); + } + } + + dispose(): void { + if (this.onClicked) this.deps.menus.onClicked.removeListener(this.onClicked); + if (this.onCommand && this.deps.commands) this.deps.commands.onCommand.removeListener(this.onCommand); + this.onClicked = undefined; + this.onCommand = undefined; + } + + private async handleClick(info: OnClickedInfo, _tab: Tab | undefined): Promise { + let url: string | undefined; + if (info.menuItemId === LINK_MENU_ID) url = info.linkUrl; + else if (info.menuItemId === MEDIA_MENU_ID) url = info.srcUrl; + else return; + await this.add(url, info.pageUrl); + } + + private async handleCommand(name: string, tab: Tab | undefined): Promise { + if (name !== GRAB_TAB_COMMAND) return; + const active = tab ?? (await this.deps.tabs.query({ active: true, currentWindow: true }))[0]; + await this.add(active?.url, active?.url); + } + + private async add(url: string | undefined, referrer: string | undefined): Promise { + if (!url || !/^https?:/i.test(url)) { + this.deps.onError?.('Velox can only download http(s) links.'); + return; + } + const spec: DownloadSpec = { url }; + if (referrer) spec.referrer = referrer; + try { + await this.deps.addDownload(spec); + } catch (err) { + this.deps.onError?.(`Velox could not start the download: ${describe(err)}`); + } + } +} + +function describe(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/extension/src/background/index.ts b/extension/src/background/index.ts index eb846c0..ed7df75 100644 --- a/extension/src/background/index.ts +++ b/extension/src/background/index.ts @@ -1,20 +1,95 @@ // Background event-page entry. // -// Brings the transport up and holds the single shared reference to it. Capture, the -// popup relay, and context menus attach here as they land (docs/05 §6 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 { 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 { + ContextMenus, + type CommandsLike, + type MenusLike, + type TabsLike, +} from './context-menus.js'; import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js'; +import type { CaptureOfferParams, CaptureRules, DownloadSpec } from '../shared/protocol/index.js'; let transport: VeloxTransport | undefined; +let rules: CaptureRules = DEFAULT_CAPTURE_RULES; + +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, +}); + +const contextMenus = new ContextMenus({ + addDownload: (spec: DownloadSpec) => mustTransport().call('download.add', spec), + menus: browser.contextMenus as unknown as MenusLike, + tabs: browser.tabs as unknown as TabsLike, + commands: browser.commands as unknown as CommandsLike, + onError: (message) => { + void browser.notifications.create({ type: 'basic', title: 'Velox', message }); + }, +}); + +async function refreshRules(): Promise { + try { + rules = await mustTransport().call('capture.getRules', {}); + } catch { + // Keep the last known rules; capture stays fail-open regardless (docs/05 §2). + } +} function onTransportState(status: TransportStatus): void { const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : ''); console.debug(`[velox] transport ${status.state}${detail ? ` — ${detail}` : ''}`); + if (status.state === 'connected') void refreshRules(); } 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); + void contextMenus.register(); + transport = await createTransport(); transport.onStateChange(onTransportState); + transport.on('event.settings.changed', (payload) => { + const keys = (payload as { keys?: string[] }).keys ?? []; + if (keys.some((k) => k.startsWith('capture.'))) void refreshRules(); + }); onTransportState(transport.status); } 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/hook.test.ts b/extension/tests/capture/hook.test.ts new file mode 100644 index 0000000..f1d7621 --- /dev/null +++ b/extension/tests/capture/hook.test.ts @@ -0,0 +1,199 @@ +// The blocking onHeadersReceived hook. +// +// Fail-open is the hard requirement (docs/05 §2, AGENT-EXT M1 DoD): daemon down, slow, or +// erroring must never cost the user a download. Those cases are written first below. + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { CaptureOfferParams, CaptureOfferResult, CaptureRules } from '../../src/shared/protocol/index.js'; +import { + CaptureHook, + type CaptureHookDeps, + type HeadersReceivedWebRequest, + type OnHeadersReceivedDetails, +} from '../../src/background/capture/index.js'; + +const RULES: CaptureRules = { + enabled: true, + monitoredExtensions: ['zip', 'iso'], + monitoredMimeTypes: ['application/octet-stream'], + minSizeBytes: 1024 * 1024, + excludedHosts: [], + rulesVersion: 1, +}; + +function detailsFor(over: Partial = {}): OnHeadersReceivedDetails { + return { + requestId: '42', + url: 'https://cdn.example.com/files/ubuntu.iso', + method: 'GET', + type: 'other', + statusCode: 200, + tabId: 3, + responseHeaders: [ + { name: 'Content-Type', value: 'application/octet-stream' }, + { name: 'Content-Length', value: String(900 * 1024 * 1024) }, + ], + documentUrl: 'https://example.com/releases', + ...over, + }; +} + +interface Harness { + hook: CaptureHook; + offer: ReturnType; + stash: { take: ReturnType; peek: ReturnType }; + getCookies: ReturnType; +} + +function makeHook(over: Partial = {}): Harness { + const { offer: offerOver, getCookies: cookiesOver, stash: stashOver, ...rest } = over; + const stashEntry = { requestId: '42', url: '', method: 'GET', tabId: 3, stashedAt: 0, headers: { referer: 'https://example.com/releases' } }; + const stash = (stashOver as unknown as Harness['stash']) ?? { + take: vi.fn(() => stashEntry), + peek: vi.fn(() => stashEntry), + }; + const offer = + (offerOver as unknown as ReturnType) ?? + vi.fn(async (): Promise => ({ action: 'ignore', reason: 'type_not_monitored' })); + const getCookies = + (cookiesOver as unknown as ReturnType) ?? + vi.fn(async () => [{ name: 'sid', value: 'abc', domain: '.example.com', secure: true }]); + const deps: CaptureHookDeps = { + offer: offer as unknown as CaptureHookDeps['offer'], + stash: stash as unknown as CaptureHookDeps['stash'], + getCookies: getCookies as unknown as CaptureHookDeps['getCookies'], + getRules: () => RULES, + origin: 'moz-extension://11111111-2222-3333-4444-555555555555', + budgetMs: 40, + ...rest, + }; + return { hook: new CaptureHook(deps), offer, stash, getCookies }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('CaptureHook — fail-open (written before the feature)', () => { + it('daemon down: offer() rejects → Firefox downloads normally', async () => { + const { hook } = makeHook({ offer: vi.fn().mockRejectedValue(new Error('transport is not connected')) }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + }); + + it('daemon slow: offer() never settles → resolves to {} within the budget', async () => { + const { hook } = makeHook({ offer: vi.fn(() => new Promise(() => {})), budgetMs: 30 }); + const started = Date.now(); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + expect(Date.now() - started).toBeGreaterThanOrEqual(20); + expect(Date.now() - started).toBeLessThan(300); + }); + + it('daemon erroring: offer() rejects with a timeout-like error → {}', async () => { + const err = Object.assign(new Error('capture.offer timed out after 750 ms'), { name: 'RpcTimeoutError' }); + const { hook } = makeHook({ offer: vi.fn().mockRejectedValue(err) }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + }); + + it('getRules() throws → {} and no offer attempted', async () => { + const offer = vi.fn(); + const { hook } = makeHook({ + offer: offer as unknown as CaptureHookDeps['offer'], + getRules: () => { + throw new Error('rules not loaded'); + }, + }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + expect(offer).not.toHaveBeenCalled(); + }); + + it('the listener never rejects, whatever the details', async () => { + const { hook } = makeHook(); + // @ts-expect-error deliberately malformed + await expect(hook.handle({})).resolves.toEqual({}); + }); +}); + +describe('CaptureHook — the happy paths', () => { + it('shouldCapture says no → {} and the offer is not made, stash not consumed', async () => { + const { hook, offer, stash } = makeHook({ getRules: () => ({ ...RULES, enabled: false }) }); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + expect(offer).not.toHaveBeenCalled(); + expect(stash.take).not.toHaveBeenCalled(); + }); + + it('offer → {action:"take"} cancels the browser download', async () => { + const { hook, offer, stash } = makeHook({ + offer: vi.fn(async () => ({ action: 'take', taskId: 't1' })) as unknown as CaptureHookDeps['offer'], + }); + await expect(hook.handle(detailsFor())).resolves.toEqual({ cancel: true }); + expect(offer).toHaveBeenCalledOnce(); + expect(stash.take).toHaveBeenCalledWith('42'); + }); + + it('offer → {action:"ignore"} lets Firefox proceed', async () => { + const { hook } = makeHook(); + await expect(hook.handle(detailsFor())).resolves.toEqual({}); + }); + + it('builds a well-formed capture.offer from details + stash + response headers', async () => { + const seen: CaptureOfferParams[] = []; + const { hook } = makeHook({ + offer: (vi.fn(async (p: CaptureOfferParams) => { + seen.push(p); + return { action: 'take', taskId: 't' }; + }) as unknown) as CaptureHookDeps['offer'], + }); + await hook.handle(detailsFor()); + expect(seen[0]).toMatchObject({ + url: 'https://cdn.example.com/files/ubuntu.iso', + method: 'GET', + tabUrl: 'https://example.com/releases', + contentType: 'application/octet-stream', + contentLength: 900 * 1024 * 1024, + referrer: 'https://example.com/releases', + origin: 'moz-extension://11111111-2222-3333-4444-555555555555', + requestId: '42', + filename: 'ubuntu.iso', + }); + expect(seen[0]!.cookies).toEqual([{ name: 'sid', value: 'abc', domain: '.example.com', secure: true }]); + }); + + it('still offers when cookie collection fails, with an empty cookie list', async () => { + const seen: CaptureOfferParams[] = []; + const { hook } = makeHook({ + offer: (async (p: CaptureOfferParams) => { + seen.push(p); + return { action: 'take', taskId: 't' }; + }) as CaptureHookDeps['offer'], + getCookies: (async () => { + throw new Error('no cookies perm'); + }) as CaptureHookDeps['getCookies'], + }); + await expect(hook.handle(detailsFor())).resolves.toEqual({ cancel: true }); + expect(seen[0]!.cookies).toEqual([]); + }); +}); + +describe('CaptureHook.attach', () => { + class FakeEvent { + listeners: Array<(d: OnHeadersReceivedDetails) => unknown> = []; + addListener = (l: (d: OnHeadersReceivedDetails) => unknown): void => void this.listeners.push(l); + removeListener = (l: (d: OnHeadersReceivedDetails) => unknown): void => { + this.listeners = this.listeners.filter((x) => x !== l); + }; + } + + it('registers a blocking responseHeaders listener and unregisters on detach', () => { + const evt = new FakeEvent(); + const wr = { onHeadersReceived: evt } as unknown as HeadersReceivedWebRequest; + const { hook } = makeHook(); + + hook.attach(wr); + expect(evt.listeners).toHaveLength(1); + expect(() => hook.attach(wr)).toThrow(/already attached/); + + hook.detach(); + expect(evt.listeners).toHaveLength(0); + }); +}); 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); + }); +}); diff --git a/extension/tests/context-menus.test.ts b/extension/tests/context-menus.test.ts new file mode 100644 index 0000000..f1bcf13 --- /dev/null +++ b/extension/tests/context-menus.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { DownloadSpec } from '../src/shared/protocol/index.js'; +import { + ContextMenus, + GRAB_TAB_COMMAND, + LINK_MENU_ID, + MEDIA_MENU_ID, + type ContextMenusDeps, +} from '../src/background/context-menus.js'; + +class Event { + fns: Array<(...a: A) => void> = []; + addListener = (f: (...a: A) => void): void => void this.fns.push(f); + removeListener = (f: (...a: A) => void): void => { + this.fns = this.fns.filter((x) => x !== f); + }; + emit(...a: A): void { + for (const f of this.fns) f(...a); + } +} + +function harness(over: Partial = {}) { + const created: Array<{ id: string; contexts: string[] }> = []; + const onClicked = new Event<[{ menuItemId: string | number; linkUrl?: string; srcUrl?: string; pageUrl?: string }, unknown]>(); + const onCommand = new Event<[string, unknown]>(); + const menus = { + create: (p: { id: string; title: string; contexts: string[] }) => void created.push({ id: p.id, contexts: p.contexts }), + removeAll: vi.fn(async () => undefined), + onClicked, + }; + const addDownload = vi.fn(async (_spec: DownloadSpec) => ({ taskId: 't' })); + const tabs = { query: vi.fn(async () => [{ url: 'https://example.com/watch' }]) }; + const onError = vi.fn(); + const deps: ContextMenusDeps = { + addDownload: addDownload as unknown as ContextMenusDeps['addDownload'], + menus: menus as unknown as ContextMenusDeps['menus'], + tabs: tabs as unknown as ContextMenusDeps['tabs'], + commands: { onCommand } as unknown as ContextMenusDeps['commands'], + onError, + ...over, + }; + return { cm: new ContextMenus(deps), created, onClicked, onCommand, addDownload, tabs, onError, menus }; +} + +describe('ContextMenus', () => { + it('registers a link item and a media item after clearing stale ones', async () => { + const { cm, created, menus } = harness(); + await cm.register(); + expect(menus.removeAll).toHaveBeenCalled(); + expect(created).toEqual([ + { id: LINK_MENU_ID, contexts: ['link'] }, + { id: MEDIA_MENU_ID, contexts: ['image', 'video', 'audio'] }, + ]); + }); + + it('a link click sends linkUrl + pageUrl referrer to download.add', async () => { + const { cm, onClicked, addDownload } = harness(); + await cm.register(); + onClicked.emit( + { menuItemId: LINK_MENU_ID, linkUrl: 'https://cdn.example.com/a.zip', pageUrl: 'https://example.com/p' }, + undefined, + ); + await Promise.resolve(); + expect(addDownload).toHaveBeenCalledWith({ + url: 'https://cdn.example.com/a.zip', + referrer: 'https://example.com/p', + } satisfies DownloadSpec); + }); + + it('a media click uses srcUrl', async () => { + const { cm, onClicked, addDownload } = harness(); + await cm.register(); + onClicked.emit({ menuItemId: MEDIA_MENU_ID, srcUrl: 'https://v.example.com/clip.mp4', pageUrl: 'https://v.example.com' }, undefined); + await Promise.resolve(); + expect(addDownload.mock.calls[0]![0].url).toBe('https://v.example.com/clip.mp4'); + }); + + it('ignores a click on some other extension menu item', async () => { + const { cm, onClicked, addDownload } = harness(); + await cm.register(); + onClicked.emit({ menuItemId: 'someone-elses-item', linkUrl: 'https://x/a.zip' }, undefined); + await Promise.resolve(); + expect(addDownload).not.toHaveBeenCalled(); + }); + + it('rejects a non-http link with a user-facing error, no download', async () => { + const { cm, onClicked, addDownload, onError } = harness(); + await cm.register(); + onClicked.emit({ menuItemId: LINK_MENU_ID, linkUrl: 'ftp://legacy/a.zip' }, undefined); + await Promise.resolve(); + expect(addDownload).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(expect.stringMatching(/http/)); + }); + + it('the grab-tab command downloads the active tab URL', async () => { + const { cm, onCommand, addDownload, tabs } = harness(); + await cm.register(); + onCommand.emit(GRAB_TAB_COMMAND, undefined); + await Promise.resolve(); + await Promise.resolve(); + expect(tabs.query).toHaveBeenCalled(); + expect(addDownload.mock.calls[0]![0].url).toBe('https://example.com/watch'); + }); + + it('the grab-tab command prefers the tab handed to the listener', async () => { + const { cm, onCommand, addDownload, tabs } = harness(); + await cm.register(); + onCommand.emit(GRAB_TAB_COMMAND, { url: 'https://direct.example/page' }); + await Promise.resolve(); + expect(tabs.query).not.toHaveBeenCalled(); + expect(addDownload.mock.calls[0]![0].url).toBe('https://direct.example/page'); + }); + + it('surfaces a download.add failure through onError', async () => { + const { cm, onClicked, onError } = harness({ + addDownload: vi.fn().mockRejectedValue(new Error('daemon down')) as unknown as ContextMenusDeps['addDownload'], + }); + await cm.register(); + onClicked.emit({ menuItemId: LINK_MENU_ID, linkUrl: 'https://x/a.zip' }, undefined); + await Promise.resolve(); + await Promise.resolve(); + expect(onError).toHaveBeenCalledWith(expect.stringMatching(/daemon down/)); + }); + + it('dispose() detaches listeners', async () => { + const { cm, onClicked, addDownload } = harness(); + await cm.register(); + cm.dispose(); + onClicked.emit({ menuItemId: LINK_MENU_ID, linkUrl: 'https://x/a.zip' }, undefined); + await Promise.resolve(); + expect(addDownload).not.toHaveBeenCalled(); + }); +});