diff --git a/extension/src/background/capture/rules.ts b/extension/src/background/capture/rules.ts new file mode 100644 index 0000000..339e091 --- /dev/null +++ b/extension/src/background/capture/rules.ts @@ -0,0 +1,195 @@ +// shouldCapture() — the one decision that makes or breaks a header-hook download manager. +// +// docs/05 §2: capture when ANY positive signal holds and NONE of the vetoes do. The +// vetoes are checked first and absolutely: a monitored .zip on an excluded host is not +// captured. Decisions are a pure function of the candidate plus the daemon's rules +// (mirrored via capture.getRules, so this never drifts from the daemon's policy). +// +// The companion decision table in tests/capture/rules.test.ts is the spec; it was written +// before this file and every branch here exists to satisfy a row in it. + +import type { CaptureRules, Headers } from '../../shared/protocol/index.js'; + +export type ResourceType = + | 'main_frame' + | 'sub_frame' + | 'stylesheet' + | 'script' + | 'image' + | 'font' + | 'object' + | 'xmlhttprequest' + | 'ping' + | 'csp_report' + | 'media' + | 'websocket' + | 'other' + | (string & {}); + +export interface CaptureCandidate { + /** The effective request URL (after redirects). */ + url: string; + method: string; + statusCode: number; + type: ResourceType; + /** Response headers, lower-cased names (see capture/headers.ts normalizeHeaders). */ + responseHeaders: Headers; + /** The request headers the browser sent, from the stash. Lower-cased names. */ + requestHeaders: Headers; + /** The document that initiated the request (originUrl / documentUrl / tab URL). */ + documentUrl?: string | null; + /** Whether the user held the bypass modifier on the click that started this. */ + bypassHeld?: boolean; +} + +export type CaptureReason = + | 'capture_disabled' + | 'not_a_get' + | 'bad_status' + | 'bypass_modifier' + | 'blob_or_data_origin' + | 'excluded_host' + | 'page_range_request' + | 'streaming_media' + | 'html_navigation' + | 'content_disposition' + | 'monitored_extension' + | 'monitored_mime' + | 'over_min_size' + | 'no_rule_matched'; + +export interface CaptureDecision { + capture: boolean; + reason: CaptureReason; +} + +const RENDERABLE_TYPES = new Set([ + 'text/html', + 'application/xhtml+xml', + 'text/plain', + 'text/xml', + 'application/xml', + 'application/json', + 'application/pdf', +]); + +const STREAMING_MANIFEST_TYPES = new Set([ + 'application/vnd.apple.mpegurl', + 'application/x-mpegurl', + 'audio/mpegurl', + 'audio/x-mpegurl', + 'application/dash+xml', +]); + +export function shouldCapture(c: CaptureCandidate, rules: CaptureRules): CaptureDecision { + const no = (reason: CaptureReason): CaptureDecision => ({ capture: false, reason }); + const yes = (reason: CaptureReason): CaptureDecision => ({ capture: true, reason }); + + // --- vetoes, in priority order -------------------------------------------------- + if (!rules.enabled) return no('capture_disabled'); + if (c.method.toUpperCase() !== 'GET') return no('not_a_get'); + if (c.statusCode !== 200 && c.statusCode !== 206) return no('bad_status'); + if (c.bypassHeld) return no('bypass_modifier'); + + if (isBlobOrData(c.documentUrl) || isBlobOrData(c.url)) return no('blob_or_data_origin'); + + const host = hostOf(c.url); + if (host && hostExcluded(host, rules.excludedHosts)) return no('excluded_host'); + + // A Range the page put on the request itself (a media player, pdf.js): the daemon, + // not us, does ranged fetches — so this is playback/rendering, not a download. + if (c.requestHeaders['range'] !== undefined) return no('page_range_request'); + + const contentType = mimeOf(c.responseHeaders); + if (c.type === 'media' || isStreamingManifest(contentType, c.url)) return no('streaming_media'); + + const attachment = dispositionIsAttachment(c.responseHeaders['content-disposition']); + + if (isNavigation(c.type) && isHtml(contentType) && !attachment) return no('html_navigation'); + + // --- positives --------------------------------------------------------------------- + if (attachment) return yes('content_disposition'); + + const ext = filenameExtension(c.url); + if (ext && rules.monitoredExtensions.includes(ext)) return yes('monitored_extension'); + + if (contentType && contentType !== 'text/html' && rules.monitoredMimeTypes.includes(contentType)) { + return yes('monitored_mime'); + } + + const length = Number(c.responseHeaders['content-length']); + if (Number.isFinite(length) && length > rules.minSizeBytes && !isRenderable(contentType)) { + return yes('over_min_size'); + } + + return no('no_rule_matched'); +} + +// --- helpers ---------------------------------------------------------------------- + +function isBlobOrData(url: string | null | undefined): boolean { + return typeof url === 'string' && /^(blob:|data:)/i.test(url); +} + +function hostOf(url: string): string { + try { + return new URL(url).hostname; + } catch { + return ''; + } +} + +/** `example.com` matches that host exactly; `*.example.com` matches any subdomain of it. */ +export function hostExcluded(host: string, patterns: readonly string[]): boolean { + for (const p of patterns) { + if (p.startsWith('*.')) { + if (host === p.slice(2) || host.endsWith(p.slice(1))) return true; + } else if (host === p) { + return true; + } + } + return false; +} + +function mimeOf(headers: Headers): string { + const raw = headers['content-type']; + if (!raw) return ''; + return raw.split(';', 1)[0]!.trim().toLowerCase(); +} + +function isNavigation(type: ResourceType): boolean { + return type === 'main_frame' || type === 'sub_frame'; +} + +function isHtml(mime: string): boolean { + return mime === 'text/html' || mime === 'application/xhtml+xml'; +} + +function isRenderable(mime: string): boolean { + return RENDERABLE_TYPES.has(mime) || mime.startsWith('image/'); +} + +function isStreamingManifest(mime: string, url: string): boolean { + if (STREAMING_MANIFEST_TYPES.has(mime)) return true; + const path = pathOf(url).toLowerCase(); + return path.endsWith('.m3u8') || path.endsWith('.mpd'); +} + +function dispositionIsAttachment(value: string | undefined): boolean { + return value !== undefined && /^\s*attachment\s*(;|$)/i.test(value); +} + +function pathOf(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url.split(/[?#]/, 1)[0]!; + } +} + +export function filenameExtension(url: string): string { + const path = pathOf(url); + const base = path.slice(path.lastIndexOf('/') + 1); + const dot = base.lastIndexOf('.'); + return dot > 0 ? base.slice(dot + 1).toLowerCase() : ''; +} diff --git a/extension/tests/capture/rules.test.ts b/extension/tests/capture/rules.test.ts new file mode 100644 index 0000000..69d374c --- /dev/null +++ b/extension/tests/capture/rules.test.ts @@ -0,0 +1,221 @@ +// shouldCapture() decision table — written before the implementation. +// +// The rule (docs/05 §2): capture when ANY positive signal holds AND NONE of the vetoes +// do. Too eager and we hijack page navigations; too shy and we are not a download +// manager. Every row here is a case that has to stay pinned. + +import { describe, expect, it } from 'vitest'; + +import type { CaptureRules, Headers } from '../../src/shared/protocol/index.js'; +import { shouldCapture, type CaptureCandidate } from '../../src/background/capture/rules.js'; + +const RULES: CaptureRules = { + enabled: true, + monitoredExtensions: ['zip', 'iso', 'mp4', 'dmg', 'pkg'], + monitoredMimeTypes: ['application/octet-stream', 'application/x-iso9660-image', 'video/mp4'], + minSizeBytes: 1024 * 1024, // 1 MiB + excludedHosts: ['mail.example.com', '*.internal.example.org'], + bypassModifier: 'alt', + rulesVersion: 1, +}; + +function candidate(over: Partial = {}): CaptureCandidate { + return { + url: 'https://cdn.example.com/files/report.bin', + method: 'GET', + statusCode: 200, + type: 'other', + responseHeaders: {}, + requestHeaders: {}, + documentUrl: 'https://example.com/downloads', + bypassHeld: false, + ...over, + }; +} + +const h = (o: Record): Headers => o; + +interface Row { + name: string; + candidate: CaptureCandidate; + capture: boolean; + reason: string; + rules?: CaptureRules; +} + +const TABLE: Row[] = [ + // --- positives --------------------------------------------------------------------- + { + name: 'Content-Disposition: attachment', + candidate: candidate({ + url: 'https://cdn.example.com/generate?id=9', + responseHeaders: h({ 'content-disposition': 'attachment; filename="q3.csv"', 'content-type': 'text/csv' }), + }), + capture: true, + reason: 'content_disposition', + }, + { + name: 'attachment on a main_frame is still a download, not a navigation', + candidate: candidate({ + type: 'main_frame', + responseHeaders: h({ 'content-disposition': 'attachment; filename="page.html"', 'content-type': 'text/html' }), + }), + capture: true, + reason: 'content_disposition', + }, + { + name: 'monitored file extension', + candidate: candidate({ url: 'https://cdn.example.com/a/ubuntu-26.04.iso?sig=abc' }), + capture: true, + reason: 'monitored_extension', + }, + { + name: 'monitored MIME type (not text/html)', + candidate: candidate({ responseHeaders: h({ 'content-type': 'application/octet-stream' }) }), + capture: true, + reason: 'monitored_mime', + }, + { + name: 'over the size threshold and not a renderable type', + candidate: candidate({ + responseHeaders: h({ 'content-type': 'application/x-tar', 'content-length': String(8 * 1024 * 1024) }), + }), + capture: true, + reason: 'over_min_size', + }, + + // --- vetoes ---------------------------------------------------------------------- + { + name: 'capture disabled in rules', + candidate: candidate({ url: 'https://cdn.example.com/a.zip' }), + rules: { ...RULES, enabled: false }, + capture: false, + reason: 'capture_disabled', + }, + { + name: 'excluded host (exact)', + candidate: candidate({ url: 'https://mail.example.com/attach/a.zip' }), + capture: false, + reason: 'excluded_host', + }, + { + name: 'excluded host (wildcard)', + candidate: candidate({ url: 'https://build07.internal.example.org/artifacts/out.zip' }), + capture: false, + reason: 'excluded_host', + }, + { + name: 'HTML page navigation', + candidate: candidate({ + type: 'main_frame', + responseHeaders: h({ 'content-type': 'text/html; charset=utf-8', 'content-length': String(4 * 1024 * 1024) }), + }), + capture: false, + reason: 'html_navigation', + }, + { + name: 'blob: document origin', + candidate: candidate({ url: 'https://cdn.example.com/a.zip', documentUrl: 'blob:https://example.com/uuid' }), + capture: false, + reason: 'blob_or_data_origin', + }, + { + name: 'the download URL itself is a blob:', + candidate: candidate({ url: 'blob:https://example.com/2b7f-...' }), + capture: false, + reason: 'blob_or_data_origin', + }, + { + name: 'bypass modifier held', + candidate: candidate({ url: 'https://cdn.example.com/a.zip', bypassHeld: true }), + capture: false, + reason: 'bypass_modifier', + }, + { + name: 'streaming media — HLS manifest MIME', + candidate: candidate({ + url: 'https://v.example.com/live/index.m3u8', + responseHeaders: h({ 'content-type': 'application/vnd.apple.mpegurl' }), + }), + capture: false, + reason: 'streaming_media', + }, + { + name: 'streaming media —