Files
samiandClaude Sonnet 5 05b650b8ec 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
2026-09-10 13:55:05 +04:00

222 lines
7.0 KiB
TypeScript

// 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);
});
});