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:
2026-09-10 13:55:05 +04:00
co-authored by Claude Sonnet 5
parent 801bcaae12
commit 05b650b8ec
2 changed files with 416 additions and 0 deletions
+195
View File
@@ -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() : '';
}