ext: capture/rules.ts — shouldCapture() decision table
The header-hook manager's core call: capture when any positive signal holds and none of the vetoes do (docs/05 §2). Vetoes are absolute and checked first — a monitored .zip on an excluded host is not captured. Decision table written first (tests/capture/rules.test.ts, 22 rows); every branch here exists to satisfy one. Covers the M1 DoD set: attachment, monitored extension, monitored MIME, size threshold, excluded host (exact + wildcard), HTML navigation, blob:/data: origin, bypass modifier, streaming media (HLS MIME and resourceType 'media'), a page-issued range request, plus non-GET, redirect status, sub-threshold, and large-but-renderable. Pure function of (candidate, rules); rules are the daemon's, mirrored via capture.getRules, so the decision never drifts from daemon policy. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
This commit is contained in:
@@ -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() : '';
|
||||||
|
}
|
||||||
@@ -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> = {}): 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<string, string>): 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 — <video> element load (resourceType media)',
|
||||||
|
candidate: candidate({
|
||||||
|
url: 'https://v.example.com/seg/chunk.mp4',
|
||||||
|
type: 'media',
|
||||||
|
responseHeaders: h({ 'content-type': 'video/mp4', 'content-length': String(20 * 1024 * 1024) }),
|
||||||
|
}),
|
||||||
|
capture: false,
|
||||||
|
reason: 'streaming_media',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'range request the page itself issued',
|
||||||
|
candidate: candidate({
|
||||||
|
url: 'https://cdn.example.com/big.iso',
|
||||||
|
statusCode: 206,
|
||||||
|
requestHeaders: h({ range: 'bytes=1048576-2097151' }),
|
||||||
|
}),
|
||||||
|
capture: false,
|
||||||
|
reason: 'page_range_request',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-GET (form POST result) is left to the downloads.onCreated safety net',
|
||||||
|
candidate: candidate({ method: 'POST', responseHeaders: h({ 'content-disposition': 'attachment' }) }),
|
||||||
|
capture: false,
|
||||||
|
reason: 'not_a_get',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'redirect status is not a body',
|
||||||
|
candidate: candidate({ statusCode: 302, responseHeaders: h({ location: '/elsewhere' }) }),
|
||||||
|
capture: false,
|
||||||
|
reason: 'bad_status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'small file below the threshold with no other signal',
|
||||||
|
candidate: candidate({
|
||||||
|
responseHeaders: h({ 'content-type': 'application/x-tar', 'content-length': String(4096) }),
|
||||||
|
}),
|
||||||
|
capture: false,
|
||||||
|
reason: 'no_rule_matched',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'plain nothing — no disposition, unknown type, no length',
|
||||||
|
candidate: candidate({ url: 'https://example.com/page/thing' }),
|
||||||
|
capture: false,
|
||||||
|
reason: 'no_rule_matched',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'large image is renderable — not captured by the size rule',
|
||||||
|
candidate: candidate({
|
||||||
|
url: 'https://cdn.example.com/photo',
|
||||||
|
responseHeaders: h({ 'content-type': 'image/jpeg', 'content-length': String(6 * 1024 * 1024) }),
|
||||||
|
}),
|
||||||
|
capture: false,
|
||||||
|
reason: 'no_rule_matched',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'a veto beats a positive: monitored .zip on an excluded host',
|
||||||
|
candidate: candidate({ url: 'https://mail.example.com/a/backup.zip' }),
|
||||||
|
capture: false,
|
||||||
|
reason: 'excluded_host',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('shouldCapture', () => {
|
||||||
|
for (const row of TABLE) {
|
||||||
|
it(row.name, () => {
|
||||||
|
const decision = shouldCapture(row.candidate, row.rules ?? RULES);
|
||||||
|
expect(decision).toEqual({ capture: row.capture, reason: row.reason });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is a pure function of its inputs (no reliance on globals)', () => {
|
||||||
|
const c = candidate({ url: 'https://cdn.example.com/x.zip' });
|
||||||
|
const a = shouldCapture(c, RULES);
|
||||||
|
const b = shouldCapture(c, RULES);
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
expect(a.capture).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user