// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini /** * Shared helpers for the persona suites (tests/host/persona-*.spec.ts). * * - StateModel: the test's expectation of the database (tokens, active flags, exact description * bytes, audit rows per token), verified against the real file with assertDbInvariants. * - stateDigest: logical digest of schema + every row (WAL-proof "nothing changed" checks). * - Seeded generators for fuzz text, near-match tokens and HTTP requests. * - Exact oracles for the behaviour the README documents: CLI argument parsing, configuration * validation, Go's request parsing as far as the generators exercise it, mux routing and * Bearer authentication. Oracles are only used on inputs whose class has a single defined * outcome; malformed-by-construction classes carry an explicit allowed-status set. * * Additive module: nothing else in the harness depends on it. */ import crypto from 'node:crypto'; import { expect } from '@playwright/test'; import { expectContainersResponse, expectErrorResponse, view, type AppErrorStatus } from './assertions'; import { formatAuditOutput, formatTokenList, type AuditRow, type TokenListRow } from './cli'; import { assertDbInvariants, readOperations, readSchema, readTokens, rfc3339ToNanos, withDb, type DbInvariantReport, type OperationRow } from './db'; import { goUnquoteBytes } from './gostrings'; import type { CurlResult, HttpResult, RawHttpResponse, RawResult } from './http'; import type { Rng } from './rng'; import { TOKEN_CANDIDATE_RE, tokenId, type SecretRegistry } from './secrets'; export const ENDPOINT = '/api/v1/containers'; export const TOKEN_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; // --------------------------------------------------------------------------------------------- // Database state // --------------------------------------------------------------------------------------------- /** sha256 of schema + every token row (exact description bytes) + every operations row; 'absent' when the file does not exist. */ export function stateDigest(dbPath: string): string { try { const schema = readSchema(dbPath); const tokens = readTokens(dbPath).map((t) => [t.token, t.descriptionHex, t.active, t.created_at, t.deleted_at]); const ops = readOperations(dbPath).map((o) => [o.rowid, o.token, o.origin, o.operation, o.time]); return crypto.createHash('sha256').update(JSON.stringify({ schema, tokens, ops })).digest('hex'); } catch (e) { if (/does not exist/.test((e as Error).message)) return 'absent'; throw e; } } export interface ModelToken { token: string; description: Buffer; active: boolean; audits: number; } /** Expected database content, maintained by a test as it performs operations. */ export class StateModel { readonly tokens = new Map(); add(token: string, description: string | Buffer = ''): ModelToken { expect(this.tokens.has(token), `token ${tokenId(token)} created twice`).toBe(false); const t: ModelToken = { token, description: typeof description === 'string' ? Buffer.from(description, 'utf8') : description, active: true, audits: 0 }; this.tokens.set(token, t); return t; } deactivate(token: string): void { const t = this.tokens.get(token); expect(t?.active, `model: deactivating ${tokenId(token)} which is not active`).toBe(true); t!.active = false; } audit(token: string, n = 1): void { const t = this.tokens.get(token); if (!t) throw new Error(`model: audit for unknown token ${tokenId(token)}`); t.audits += n; } /** 'active' | 'inactive' | undefined (unknown) — the lookup the app performs (byte-exact). */ lookup(value: string): 'active' | 'inactive' | undefined { const t = this.tokens.get(value); return t ? (t.active ? 'active' : 'inactive') : undefined; } active(): string[] { return [...this.tokens.values()].filter((t) => t.active).map((t) => t.token); } inactive(): string[] { return [...this.tokens.values()].filter((t) => !t.active).map((t) => t.token); } all(): string[] { return [...this.tokens.keys()]; } get auditTotal(): number { let n = 0; for (const t of this.tokens.values()) n += t.audits; return n; } /** Structural invariants + exact equality of tokens, flags, descriptions and per-token audit counts. */ verifyDb(dbPath: string, label = 'database matches the model', opts: { auditBeforeDeletion?: boolean } = {}): DbInvariantReport { const report = assertDbInvariants(dbPath, { tokenCount: this.tokens.size, operationCount: this.auditTotal, auditBeforeDeletion: opts.auditBeforeDeletion }); const rows = new Map(readTokens(dbPath).map((r) => [r.token, r])); const perToken = new Map(); for (const o of readOperations(dbPath)) perToken.set(o.token, (perToken.get(o.token) ?? 0) + 1); const problems: string[] = []; for (const t of this.tokens.values()) { const id = tokenId(t.token); const r = rows.get(t.token); if (!r) { problems.push(`${id}: missing from tokens`); continue; } if (r.active !== (t.active ? 1 : 0)) problems.push(`${id}: active=${r.active}, expected ${t.active}`); if (r.descriptionHex !== t.description.toString('hex').toUpperCase()) problems.push(`${id}: description bytes differ`); const n = perToken.get(t.token) ?? 0; if (n !== t.audits) problems.push(`${id}: ${n} audit rows, expected ${t.audits}`); } for (const tok of rows.keys()) if (!this.tokens.has(tok)) problems.push(`${tokenId(tok)}: unexpected token row`); expect(problems, label).toEqual([]); return report; } /** * `token list` (scope 'active', the default: only the active tokens) or `token list all` (scope * 'all': every token) rows equal the model: set, flags, description bytes, deleted_at presence, * and the order (created_at as an instant, then token). */ verifyList(rows: TokenListRow[], label = 'token list matches the model', scope: 'active' | 'all' = 'active'): void { const problems: string[] = []; const expected = scope === 'all' ? this.tokens.size : this.active().length; if (rows.length !== expected) problems.push(`${rows.length} rows, expected ${expected} (${scope})`); for (const [i, r] of rows.entries()) { if (i > 0) { const [pa, pb] = [rfc3339ToNanos(rows[i - 1].createdAt), rfc3339ToNanos(r.createdAt)]; if (pa > pb || (pa === pb && rows[i - 1].token >= r.token)) problems.push(`row ${i} (${tokenId(r.token)}) is not ordered after row ${i - 1}`); } if (scope === 'active' && !r.active) problems.push(`${tokenId(r.token)}: inactive token printed by plain token list`); const t = this.tokens.get(r.token); if (!t) { problems.push(`${tokenId(r.token)}: unexpected row`); continue; } if (r.active !== t.active) problems.push(`${tokenId(r.token)}: ACTIVE=${r.active}`); if ((r.deletedAt === null) !== t.active) problems.push(`${tokenId(r.token)}: DELETED_AT=${r.deletedAt}`); if (!r.descriptionBytes.equals(t.description)) problems.push(`${tokenId(r.token)}: DESCRIPTION differs`); } expect(problems, label).toEqual([]); } } /** No registered token, nor any 12-character fragment of one, occurs in text. */ export function expectNoTokenFragments(text: string, secrets: SecretRegistry, where: string): void { const hits: string[] = []; for (const t of secrets.all()) { for (let i = 0; i + 12 <= t.length; i += 6) if (text.includes(t.slice(i, i + 12))) hits.push(`${tokenId(t)}[${i}..${i + 12}]`); } expect(hits, `token fragments in ${where}`).toEqual([]); } export interface ConsistentSnapshot { quickCheck: string[]; fkViolations: number; tokens: Array<{ token: string; active: number; deleted_at: string | null }>; operations: Array<{ token: string; origin: string; operation: string; time: string }>; } /** * Reads tokens and operations inside ONE read transaction (a consistent WAL snapshot), so it can * run while the server and CLI write concurrently without false foreign-key mismatches. */ export function consistentSnapshot(dbPath: string): ConsistentSnapshot { return withDb(dbPath, (db) => { db.exec('BEGIN'); try { const quickCheck = (db.prepare('PRAGMA quick_check').all() as Array>).map((r) => String(Object.values(r)[0])); const fkViolations = db.prepare('PRAGMA foreign_key_check').all().length; const tokens = db.prepare('SELECT token, active, deleted_at FROM tokens').all() as unknown as ConsistentSnapshot['tokens']; const operations = db.prepare('SELECT token, origin, operation, time FROM operations').all() as unknown as ConsistentSnapshot['operations']; return { quickCheck, fkViolations, tokens: tokens.map((t) => ({ ...t, active: Number(t.active) })), operations }; } finally { db.exec('COMMIT'); } }); } /** Structural checks on a consistent snapshot (the subset of assertDbInvariants that is meaningful mid-flight). */ export function snapshotProblems(s: ConsistentSnapshot): string[] { const problems: string[] = []; if (s.quickCheck.join() !== 'ok') problems.push(`quick_check: ${s.quickCheck.join('; ')}`); if (s.fkViolations) problems.push(`${s.fkViolations} foreign key violations`); const known = new Set(s.tokens.map((t) => t.token)); for (const t of s.tokens) { if ((t.active === 1) !== (t.deleted_at === null)) problems.push(`${tokenId(t.token)}: active=${t.active} deleted_at=${t.deleted_at}`); } for (const o of s.operations) { if (!known.has(o.token)) problems.push(`audit row for unknown token ${tokenId(o.token)}`); if (o.operation !== 'GET containers') problems.push(`operation ${JSON.stringify(o.operation)}`); } return problems; } // --------------------------------------------------------------------------------------------- // Fuzz text // --------------------------------------------------------------------------------------------- export type TextClass = | 'empty' | 'one-byte' | 'word' | 'whitespace' | 'repeated-delimiters' | 'long-repeat' | 'max-practical' | 'unicode-edge' | 'control-chars' | 'numeric-extreme' | 'random-base64' | 'random-hex' | 'random-percent' | 'sql' | 'shell' | 'format' | 'combo'; export const TEXT_CLASSES: readonly TextClass[] = [ 'empty', 'one-byte', 'word', 'whitespace', 'repeated-delimiters', 'long-repeat', 'max-practical', 'unicode-edge', 'control-chars', 'numeric-extreme', 'random-base64', 'random-hex', 'random-percent', 'sql', 'shell', 'format', 'combo', ]; /** Unicode edge cases (no NUL). Lone surrogates are deliberately absent: they are not representable in argv/env bytes. */ export const UNICODE_EDGE = [ '\u00a0', // NBSP '\u00ad', // soft hyphen '\u200b', // zero-width space '\u200d', // ZWJ '\u202e', // RTL override '\u2028', // line separator '\ufeff', // BOM '\ufffd', // replacement character '\uffff', // noncharacter '\u{10ffff}', // last code point '\u{1f600}', // astral emoji '\u{1f468}\u200d\u{1f469}\u200d\u{1f467}', // ZWJ sequence 'e\u0301', // combining '\u0130', // \u0130 (Turkish dotted I) '\u017f', // \u017f (long s, folds to S in Unicode) '\u212a', // Kelvin sign (folds to K) '\uff10\uff18', // fullwidth digits '\u0660\u0668', // Arabic-Indic digits '\u0430', // Cyrillic a '\u0435', // Cyrillic e '\u65e5\u672c\u8a9e', '\u0085', // NEL ]; export const SQL_PAYLOADS = [ `'`, `''`, `"`, `' OR '1'='1`, `' OR 1=1 --`, `'); DROP TABLE tokens; --`, `'); DROP TABLE operations; --`, `x'; DELETE FROM tokens WHERE '1'='1`, `'; UPDATE tokens SET active=1; --`, `'; INSERT INTO operations VALUES('a','b','c','d'); --`, `' UNION SELECT token, description, active, created_at, deleted_at FROM tokens --`, `%`, `_`, `*`, `\\'; ATTACH DATABASE '/tmp/x.db' AS x; --`, `1; PRAGMA writable_schema=1; --`, `'||(SELECT group_concat(token) FROM tokens)||'`, `$1`, `?`, `:name`, `@p`, ]; export const SHELL_PAYLOADS = ['$(id)', '`id`', '; id', '&& id', '| id', '> /dev/null', '$HOME', '${PATH}', "' \" \\", '*', '~', '!!', '\\$(x)', '$((1+1))']; const FORMAT_PAYLOADS = ['%s', '%d', '%v', '%!x(MISSING)', '%%', '%n', '{}', '{{.}}', '${jndi:ldap://x}', '%(x)s', '\\n', '\\x00', '\\u0000']; const DELIMS = [' ', '\t', ',', ';', ':', '=', '/', '\\', '"', "'", '\n', '\r\n', '|', '&', '?', '#', '%', '.', '-', '_']; function controlChar(rng: Rng): string { const c = rng.int(1, 32); return c === 32 ? '\x7f' : String.fromCharCode(c); } export interface FuzzText { cls: TextClass; value: string; } /** * Seeded text of a chosen (or random) class. Never contains NUL (argv/env cannot carry it). * `maxLen` bounds the long classes (default 4096 UTF-16 units; max-practical uses exactly maxLen). */ export function fuzzText(rng: Rng, opts: { classes?: readonly TextClass[]; maxLen?: number } = {}): FuzzText { const cls = rng.pick(opts.classes ?? TEXT_CLASSES); const maxLen = opts.maxLen ?? 4096; return { cls, value: notTokenShaped(textOfClass(rng, cls, maxLen)) }; } /** * Breaks every token-shaped run (43 base64url characters between non-token characters) by * inserting a '.', so generated text is never mistaken for a credential: the harness registers * token-shaped strings printed by `token list` as secrets and fails the test if they show up in * logs, where descriptions legitimately appear at TRACE. */ export function notTokenShaped(s: string): string { return s.replace(TOKEN_CANDIDATE_RE, (m) => `${m.slice(0, 21)}.${m.slice(21)}`); } export function textOfClass(rng: Rng, cls: TextClass, maxLen = 4096): string { switch (cls) { case 'empty': return ''; case 'one-byte': return String.fromCharCode(rng.pick([rng.int(0x21, 0x7e), rng.int(1, 0x1f), 0x20, 0x7f])); case 'word': return rng.string(rng.int(1, 24), 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -_.'); case 'whitespace': return Array.from({ length: rng.int(1, 12) }, () => rng.pick([' ', '\t', '\n', '\r', '\v', '\f', '\u00a0', '\u2003'])).join(''); case 'repeated-delimiters': return rng.pick(DELIMS).repeat(rng.int(2, 300)); case 'long-repeat': return rng.pick(['a', 'ab', '\u65e5', '\u{1f600}', '=', '%41', 'Bearer ']).repeat(rng.int(100, Math.max(100, Math.floor(maxLen / 8)))); case 'max-practical': return 'm'.repeat(maxLen); case 'unicode-edge': return Array.from({ length: rng.int(1, 6) }, () => rng.pick(UNICODE_EDGE)).join(rng.pick(['', ' ', 'x'])); case 'control-chars': return Array.from({ length: rng.int(1, 10) }, () => (rng.bool(0.7) ? controlChar(rng) : rng.string(1))).join(''); case 'numeric-extreme': return rng.pick(['0', '-0', '-1', '1', '65535', '65536', '2147483647', '2147483648', '-2147483649', '4294967296', '9223372036854775807', '9223372036854775808', '18446744073709551616', '1e308', '1e309', 'NaN', 'Infinity', '-Infinity', '0x10', '1_000', '00000000000000000000000000080', '\u0668\u0660', '+80', '8.0']); case 'random-base64': return rng.bytes(rng.int(1, 96)).toString(rng.bool() ? 'base64' : 'base64url'); case 'random-hex': return rng.bytes(rng.int(1, 64)).toString('hex'); case 'random-percent': return [...rng.bytes(rng.int(1, 48))].map((b) => '%' + b.toString(16).padStart(2, '0').toUpperCase()).join(''); case 'sql': return rng.pick(SQL_PAYLOADS); case 'shell': return rng.pick(SHELL_PAYLOADS); case 'format': return rng.pick(FORMAT_PAYLOADS); case 'combo': { const parts = Array.from({ length: rng.int(2, 4) }, () => textOfClass(rng, rng.pick(TEXT_CLASSES.filter((c) => c !== 'combo' && c !== 'max-practical' && c !== 'long-repeat')), 256)); return parts.join(rng.pick(['', ' ', '\n', '\t'])); } } } /** Unicode confusables for ASCII letters (Cyrillic/Greek/fullwidth look-alikes). */ const CONFUSABLES: Record = { a: '\u0430', c: '\u0441', e: '\u0435', o: '\u043e', p: '\u0440', x: '\u0445', y: '\u0443', A: '\u0410', B: '\u0412', C: '\u0421', E: '\u0415', H: '\u041d', K: '\u039a', M: '\u041c', O: '\u041e', P: '\u0420', T: '\u0422', X: '\u0425', '0': '\uff10', '1': '\uff11', '-': '\u2010', _: '\uff3f', }; export interface NearMatch { kind: string; value: string; } /** Values close to `token` but different from it (case flips, edits, confusables, encodings, whitespace). */ export function nearMatches(token: string, rng: Rng, count: number): NearMatch[] { const out: NearMatch[] = []; const at = () => rng.int(0, token.length - 1); const gens: Array<[string, () => string]> = [ ['flip-case', () => { const letters = [...token].map((c, i) => [c, i] as const).filter(([c]) => /[A-Za-z]/.test(c)); const [c, i] = rng.pick(letters); return token.slice(0, i) + (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()) + token.slice(i + 1); }], ['flip-all-case', () => [...token].map((c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase())).join('')], ['substitute', () => { const i = at(); let c = rng.pick([...TOKEN_CHARS]); while (c === token[i]) c = rng.pick([...TOKEN_CHARS]); return token.slice(0, i) + c + token.slice(i + 1); }], ['delete-char', () => { const i = at(); return token.slice(0, i) + token.slice(i + 1); }], ['insert-char', () => { const i = rng.int(0, token.length); return token.slice(0, i) + rng.pick([...TOKEN_CHARS]) + token.slice(i); }], ['swap-adjacent', () => { const cands = [...Array(token.length - 1).keys()].filter((i) => token[i] !== token[i + 1]); const i = rng.pick(cands); return token.slice(0, i) + token[i + 1] + token[i] + token.slice(i + 2); }], ['truncate', () => token.slice(0, rng.int(0, token.length - 1))], ['extend', () => token + rng.string(rng.int(1, 4), TOKEN_CHARS)], ['confusable', () => { const idx = [...token].map((c, i) => [c, i] as const).filter(([c]) => c in CONFUSABLES); const [c, i] = rng.pick(idx); return token.slice(0, i) + CONFUSABLES[c] + token.slice(i + 1); }], ['std-base64', () => token.replace(/[-_]/g, (c) => (c === '-' ? '+' : '/')) + (token.includes('-') || token.includes('_') ? '' : '=')], ['padded', () => token + '='], ['percent-encoded-char', () => { const i = at(); return token.slice(0, i) + '%' + token.charCodeAt(i).toString(16).toUpperCase() + token.slice(i + 1); }], ['inner-space', () => { const i = rng.int(1, token.length - 1); return token.slice(0, i) + ' ' + token.slice(i); }], ['zero-width', () => { const i = rng.int(0, token.length); return token.slice(0, i) + rng.pick(['\u200b', '\u200d', '\ufeff']) + token.slice(i); }], ['leading-space', () => ' ' + token], ['trailing-space', () => token + ' '], ['trailing-newline', () => token + '\n'], ['wrapped-in-quotes', () => `"${token}"`], ['wrapped-in-angle-brackets', () => `<${token}>`], ['reversed', () => [...token].reverse().join('')], ['sql-suffix', () => `${token}' OR '1'='1`], ['sql-wildcard-suffix', () => token.slice(0, 20) + '%'], ]; for (let i = 0; i < count; i++) { const [kind, g] = rng.pick(gens); const value = g(); if (value !== token) out.push({ kind, value }); } return out; } // --------------------------------------------------------------------------------------------- // Configuration oracle (README "Configuration") // --------------------------------------------------------------------------------------------- export type AppEnv = { HTTP_ADDR?: string; HTTP_PORT?: string; LOG_LEVEL?: string }; export const VALID_ADDRS = ['127.0.0.1', '0.0.0.0', '::1', '::', '10.1.2.3', '255.255.255.255', '::ffff:1.2.3.4', '2001:db8::1', 'fe80::1', '1:2:3:4:5:6:7:8', '0:0:0:0:0:0:0:1', '1:2:3:4:5:6:1.2.3.4', 'FFFF::']; /** Values Go's net.ParseIP rejects (leading zeros, zones, brackets, ports, out of range, too many groups...). */ export const INVALID_ADDRS = ['010.0.0.1', '1.2.3.04', 'fe80::1%eth0', '::1%1', '[::1]', '127.0.0.1:80', '256.0.0.0', '1.2.3', '1.2.3.4.5', '1::2::3', ':::', '1:2:3:4:5:6:7:8:9', 'localhost', ' 127.0.0.1', '127.0.0.1 ', '0x7f000001', '2130706433', '\uff11\uff12\uff17.0.0.1']; export function asciiEqualFold(a: string, b: string): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { const x = a.charCodeAt(i); const y = b.charCodeAt(i); const lx = x >= 65 && x <= 90 ? x + 32 : x; const ly = y >= 65 && y <= 90 ? y + 32 : y; if (lx !== ly) return false; } return true; } export function isValidPort(v: string): boolean { return /^[0-9]+$/.test(v) && BigInt(v) >= 1n && BigInt(v) <= 65535n; } export function isValidLogLevel(v: string): boolean { return ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'].some((l) => asciiEqualFold(v, l)); } /** Generated HTTP_ADDR value with known validity (pools, or random text forced outside the IP alphabet). */ export function fuzzAddr(rng: Rng): { value: string; valid: boolean } { const r = rng.next(); if (r < 0.3) return { value: rng.pick(VALID_ADDRS), valid: true }; if (r < 0.6) return { value: rng.pick(INVALID_ADDRS), valid: false }; let v = fuzzText(rng, { maxLen: 512 }).value; if (v === '') return { value: v, valid: false }; if (/^[0-9a-fA-F:.]*$/.test(v)) v = 'z' + v; // guaranteed not an IP literal return { value: v, valid: false }; } export function fuzzPort(rng: Rng): string { const r = rng.next(); if (r < 0.25) return String(rng.int(1, 65535)); if (r < 0.35) return '0'.repeat(rng.int(1, 30)) + String(rng.int(1, 65535)); if (r < 0.5) return rng.pick(['0', '65536', '-80', '+80', ' 80', '80 ', '8_0', '80.0', '0x50', '\uff11', '\u0668\u0660', '99999999999999999999999', '00000', '1e3']); return fuzzText(rng, { maxLen: 512 }).value; } export function fuzzLogLevel(rng: Rng): string { const r = rng.next(); if (r < 0.35) { const l = rng.pick(['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE']); return [...l].map((c) => (rng.bool() ? c.toLowerCase() : c)).join(''); } if (r < 0.55) return rng.pick(['\u0130NFO', '\u0131nfo', '\u017f'.repeat(0) + 'DEBUG\u200b', 'INFO ', ' info', 'warning', 'ERR', 'FATAL', 'TRACE\n', 'debug2', '\uff29\uff2e\uff26\uff2f', 'tRaCe\t', 'INF0']); return fuzzText(rng, { maxLen: 512 }).value; } export type ConfigOutcome = { ok: true } | { ok: false; message: RegExp | string; quoted?: { variable: string; value: string } }; /** * Expected result of LoadConfig with DB_PATH set and the given optional variables (`undefined` = unset). * For invalid values the message embeds the Go-quoted value; compare it with `matchesConfigError`. */ export function configOutcome(env: AppEnv, addrValid?: boolean): ConfigOutcome { const checks: Array<[keyof AppEnv, (v: string) => boolean, string]> = [ ['HTTP_ADDR', () => addrValid ?? true, 'must be an IPv4 or IPv6 address'], ['HTTP_PORT', isValidPort, 'must be an integer between 1 and 65535'], ['LOG_LEVEL', isValidLogLevel, 'must be one of ERROR, WARN, INFO, DEBUG, TRACE'], ]; for (const [k, valid, tail] of checks) { const v = env[k]; if (v === undefined) continue; if (v === '') return { ok: false, message: `configuration error: ${k} must not be empty` }; if (!valid(v)) return { ok: false, message: tail, quoted: { variable: k, value: v } }; } return { ok: true }; } /** True when `message` (text after `dpsapi: `) is the expected configuration error, comparing the quoted value byte-exact. */ export function matchesConfigError(message: string | undefined, exp: Exclude): boolean { if (message === undefined) return false; if (!exp.quoted) return message === exp.message; const prefix = `configuration error: invalid ${exp.quoted.variable} `; const suffix = `: ${exp.message}`; if (!message.startsWith(prefix) || !message.endsWith(suffix)) return false; try { return goUnquoteBytes(message.slice(prefix.length, message.length - suffix.length)).equals(Buffer.from(exp.quoted.value, 'utf8')); } catch { return false; } } // --------------------------------------------------------------------------------------------- // CLI oracle (README "CLI", "Exit codes and output") // --------------------------------------------------------------------------------------------- export type CliOutcome = | { kind: 'help'; exitCode: 0 } | { kind: 'list'; exitCode: 0; scope: 'active' | 'all' } | { kind: 'new'; exitCode: 0; description: string } | { kind: 'delete'; exitCode: 0; token: string } | { kind: 'audit'; exitCode: 0; selection: AuditSelection } | { kind: 'error'; exitCode: 1 | 2; message: string; usage: boolean }; /** * Expected outcome of `dpsapi ` with a valid configuration against the model's database: * `help` (exact) prints the usage text on stdout; `token list` lists the active tokens and * `token list all` every token (`all` exact, any other single argument or more than one is a * usage error); `audit all` (`all` exact) prints every audit row; see README "CLI" for the rest. * (`audit` with a valid argument is always exit 0 here: an unparseable stored time can only be * produced by a test-owned write, which no model-driven test performs.) */ export function cliOutcome(args: string[], model: StateModel): CliOutcome { const usage = (m: string): CliOutcome => ({ kind: 'error', exitCode: 2, message: `usage error: ${m}`, usage: true }); if (args.length === 0) throw new Error('server mode is not a CLI outcome'); if (args[0] === 'help') return args.length === 1 ? { kind: 'help', exitCode: 0 } : usage('help takes no arguments'); if (args[0] === 'audit') { const a = auditArgsOutcome(args.slice(1)); return a.ok ? { kind: 'audit', exitCode: 0, selection: a.selection } : usage(a.message); } if (args[0] !== 'token') return usage('unknown command'); if (args.length === 1) return usage('missing token subcommand'); switch (args[1]) { case 'list': if (args.length > 3) return usage('token list takes at most one argument'); if (args.length === 3 && args[2] !== 'all') return usage('unknown token list argument'); return { kind: 'list', exitCode: 0, scope: args.length === 3 ? 'all' : 'active' }; case 'new': return args.length <= 3 ? { kind: 'new', exitCode: 0, description: args[2] ?? '' } : usage('token new takes at most one argument'); case 'delete': { if (args.length !== 3) return usage('token delete requires exactly one argument'); const st = model.lookup(args[2]); if (st === 'active') return { kind: 'delete', exitCode: 0, token: args[2] }; return { kind: 'error', exitCode: 1, message: st === 'inactive' ? 'token already inactive' : 'token not found', usage: false }; } default: return usage('unknown token subcommand'); } } /** * Expected exact stdout of `token list` (scope 'active') or `token list all` (scope 'all') for the * stored rows: ordered by created_at as an instant, then token (bytewise), padded table. */ export function expectedTokenListStdout(dbPath: string, scope: 'active' | 'all' = 'active'): string { const rows = readTokens(dbPath) .filter((t) => scope === 'all' || t.active === 1) .map((t) => [rfc3339ToNanos(t.created_at), t] as const) .sort(([ta, a], [tb, b]) => (ta < tb ? -1 : ta > tb ? 1 : a.token < b.token ? -1 : a.token > b.token ? 1 : 0)) .map(([, t]) => t); return formatTokenList(rows.map((t) => ({ token: t.token, description: Buffer.from(t.descriptionHex, 'hex'), active: t.active === 1, createdAt: t.created_at, deletedAt: t.deleted_at }))); } /** * Near misses of `help` / `token list all` / `audit all` (case, blanks, look-alikes, extra * arguments) for the argv fuzzers. AUDIT_ALL_ARGV_NEAR_MISSES are complete argv lists. */ export const HELP_NEAR_MISSES: readonly string[][] = [['Help'], ['HELP'], ['-h'], ['--help'], ['-help'], ['help '], [' help'], ['hel'], ['helps'], ['h\u0435lp'], ['help', 'help'], ['help', ''], ['help', 'token'], ['help', '--all'], ['token', 'help'], ['audit', 'help']]; export const AUDIT_ALL_ARGV_NEAR_MISSES: readonly string[][] = [ ...[['ALL'], ['All'], ['all1'], ['1all'], [' all'], ['alls'], ['--all'], ['all '], ['\u0430ll']].map((a) => ['audit', ...a]), ['audit', 'all', 'x'], ['audit', 'all', 'all'], ['audit', 'all', ''], ['Audit', 'all'], ['AUDIT', 'ALL'], ['token', 'audit', 'all'], ['all'], ]; export const LIST_ALL_NEAR_MISSES: readonly string[][] = [['token', 'list', 'ALL'], ['token', 'list', 'All'], ['token', 'list', ''], ['token', 'list', '--all'], ['token', 'list', '-a'], ['token', 'list', ' all'], ['token', 'list', 'all '], ['token', 'list', 'al'], ['token', 'list', 'alll'], ['token', 'list', '\u0430ll'], ['token', 'list', 'active'], ['token', 'list', 'all', 'all'], ['token', 'list', 'all', ''], ['token', 'list', '', 'all'], ['token', 'list', 'x', 'y', 'z']]; // --------------------------------------------------------------------------------------------- // Audit oracle (README "audit [all | n | n]") // --------------------------------------------------------------------------------------------- export const MAX_INT64 = 9223372036854775807n; const NS_PER_SECOND = 1_000_000_000n; /** Window units (lowercase only) in nanoseconds. */ export const AUDIT_UNITS: Readonly> = { s: NS_PER_SECOND, m: 60n * NS_PER_SECOND, h: 3600n * NS_PER_SECOND, d: 86_400n * NS_PER_SECOND, w: 604_800n * NS_PER_SECOND, }; export const AUDIT_DEFAULT_LIMIT = 10n; /** Largest accepted count per unit: the window must fit Go's time.Duration (int64 ns). */ export const AUDIT_MAX_WINDOW: Readonly> = { s: MAX_INT64 / AUDIT_UNITS.s, m: MAX_INT64 / AUDIT_UNITS.m, h: MAX_INT64 / AUDIT_UNITS.h, d: MAX_INT64 / AUDIT_UNITS.d, w: MAX_INT64 / AUDIT_UNITS.w, }; /** * What `audit` selects: every row (`audit all`, explicit — never implied by a zero value), the * latest `limit` rows, or every row with time >= now - windowNs. */ export type AuditSelection = { all: true } | { limit: bigint } | { windowNs: bigint }; export type AuditArgsOutcome = { ok: true; selection: AuditSelection } | { ok: false; message: string }; /** * The app's parseAuditArgs: no argument → latest 10; exactly `all` (lowercase, byte-exact) → * every row; ASCII digits (n >= 1, n <= MaxInt64) → latest n; ASCII digits + one lowercase unit * letter with n >= 1 and n × unit <= MaxInt64 ns → window. Anything else (`ALL`, `All`, ` all`, * `--all`, `all1`...) is `invalid audit argument`; more than one argument (also `all x`) is * `audit takes at most one argument`. */ export function auditArgsOutcome(args: readonly string[]): AuditArgsOutcome { if (args.length === 0) return { ok: true, selection: { limit: AUDIT_DEFAULT_LIMIT } }; if (args.length > 1) return { ok: false, message: 'audit takes at most one argument' }; if (args[0] === 'all') return { ok: true, selection: { all: true } }; const invalid: AuditArgsOutcome = { ok: false, message: 'invalid audit argument' }; let digits = args[0]; let unit: bigint | undefined; const last = digits.slice(-1); if (last in AUDIT_UNITS) { unit = AUDIT_UNITS[last as keyof typeof AUDIT_UNITS]; digits = digits.slice(0, -1); } if (!/^[0-9]+$/.test(digits)) return invalid; const n = BigInt(digits); if (n < 1n || n > MAX_INT64) return invalid; if (unit === undefined) return { ok: true, selection: { limit: n } }; if (n > MAX_INT64 / unit) return invalid; return { ok: true, selection: { windowNs: n * unit } }; } /** Single-argument near misses of `audit all`: each is `invalid audit argument` (case-sensitive, exact). */ export const AUDIT_ALL_NEAR_MISSES: readonly string[][] = [['ALL'], ['All'], ['aLL'], ['all1'], ['1all'], [' all'], ['all '], ['alls'], ['al'], ['--all'], ['-a'], ['\u0430ll'], ['all\n'], ['everything'], ['*']]; /** `audit all` with extra arguments: each is `audit takes at most one argument`. */ export const AUDIT_ALL_EXTRA_ARGS: readonly string[][] = [['all', 'x'], ['all', 'all'], ['all', ''], ['all', '10'], ['10', 'all'], ['', 'all'], ['all', '1h', 'x']]; /** Rows in the order `audit` prints them: parsed time, then insertion order (rowid). */ export function chronological(ops: readonly T[]): T[] { return ops .map((o) => [rfc3339ToNanos(o.time), o] as const) .sort(([ta, a], [tb, b]) => (ta < tb ? -1 : ta > tb ? 1 : a.rowid - b.rowid)) .map(([, o]) => o); } /** * The rows `audit` must print for a selection, given the database rows (readOperations) and, * for a window, the application's "now" in ns since the epoch. `all`: every row, oldest first. */ export function selectAuditRows(ops: readonly OperationRow[], sel: AuditSelection, nowNs?: bigint): OperationRow[] { const sorted = chronological(ops); if ('all' in sel) return sorted; if ('limit' in sel) return sel.limit >= BigInt(sorted.length) ? sorted : sorted.slice(sorted.length - Number(sel.limit)); if (nowNs === undefined) throw new Error('selectAuditRows: a window selection needs nowNs'); const since = nowNs - sel.windowNs; return sorted.filter((o) => rfc3339ToNanos(o.time) >= since); } /** * Checks `audit ` output whose "now" is only known to lie in [t0Ns, t1Ns] (taken right * before and after the invocation): the output must be exactly the chronological suffix of the * rows selected at some instant of that interval — every row at or after t1 - window is printed, * no row before t0 - window is, in order, byte-exact. Returns how many rows fell in the ambiguous * band [t0 - window, t1 - window) (0 = the output was fully determined). */ export function expectAuditWindow(rows: readonly AuditRow[], ops: readonly OperationRow[], windowNs: bigint, t0Ns: bigint, t1Ns: bigint, label = 'audit window'): number { const sorted = chronological(ops); const fewest = selectAuditRows(ops, { windowNs }, t1Ns).length; const most = selectAuditRows(ops, { windowNs }, t0Ns).length; expect(rows.length >= fewest && rows.length <= most, `${label}: ${rows.length} rows printed, expected between ${fewest} and ${most}`).toBe(true); expect(formatAuditOutput(rows), `${label}: output is the chronological suffix of the stored rows`).toBe(formatAuditOutput(sorted.slice(sorted.length - rows.length))); return most - fewest; } /** Expected exact stdout of `audit` for the given selection against the stored rows (`all` and counts need no `nowNs`). */ export function expectedAuditStdout(ops: readonly OperationRow[], sel: AuditSelection, nowNs?: bigint): string { return formatAuditOutput(selectAuditRows(ops, sel, nowNs)); } export type AuditArgClass = | 'all' | 'all-near-miss' | 'count' | 'count-leading-zeros' | 'count-extreme' | 'window' | 'window-extreme' | 'zero' | 'signed' | 'whitespace' | 'bad-unit' | 'unit-only' | 'number-syntax' | 'two-args' | 'empty' | 'text'; /** Seeded `audit` argument lists of every class (valid and invalid); the oracle decides the outcome. */ export function fuzzAuditArgs(rng: Rng): { cls: AuditArgClass; args: string[] } { const units = Object.keys(AUDIT_UNITS) as Array; const digits = () => String(rng.pick([1, 2, 3, 5, 9, 10, 11, 25, 99, 100, 1000, rng.int(1, 100_000)])); const zeros = () => '0'.repeat(rng.int(1, rng.bool(0.1) ? 300 : 6)); const cls = rng.pick(['all', 'all-near-miss', 'count', 'count', 'count-leading-zeros', 'count-extreme', 'window', 'window', 'window-extreme', 'zero', 'signed', 'whitespace', 'bad-unit', 'unit-only', 'number-syntax', 'two-args', 'empty', 'text']); const one = (v: string) => ({ cls, args: [v] }); switch (cls) { case 'all': return one('all'); case 'all-near-miss': return rng.bool(0.8) ? one(rng.pick(AUDIT_ALL_NEAR_MISSES)[0]) : { cls, args: [...rng.pick(AUDIT_ALL_EXTRA_ARGS)] }; case 'count': return rng.bool(0.15) ? { cls, args: [] } : one(digits()); case 'count-leading-zeros': return one(zeros() + digits() + (rng.bool(0.4) ? rng.pick(units) : '')); case 'count-extreme': return one(rng.pick(['9223372036854775807', '9223372036854775806', '9223372036854775808', '18446744073709551615', '18446744073709551616', '99999999999999999999', '0'.repeat(rng.int(1, 40)) + '9223372036854775807', '9'.repeat(rng.int(19, 400)), '1' + '0'.repeat(rng.int(18, 30))])); case 'window': return one(digits() + rng.pick(units)); case 'window-extreme': { const u = rng.pick(units); const max = AUDIT_MAX_WINDOW[u]; return one(String(max + BigInt(rng.pick([-1, 0, 0, 1, 2]))) + u); } case 'zero': return one(zeros() + (rng.bool() ? rng.pick(units) : '')); case 'signed': return one(rng.pick(['-', '+', '--', '-+', '−']) + digits() + (rng.bool(0.4) ? rng.pick(units) : '')); case 'whitespace': { const ws = () => rng.pick([' ', '\t', '\n', '\r', ' ', ' ', '\v']); const d = digits(); const u = rng.bool(0.5) ? rng.pick(units) : ''; return one(rng.pick([ws() + d + u, d + u + ws(), d + ws() + u, ws(), `${d}${ws()}${d}`])); } case 'bad-unit': return one(digits() + rng.pick(['S', 'M', 'H', 'D', 'W', 'y', 'ms', 'us', 'µs', 'ns', 'sec', 'min', 'hours', 'day', 'x', 'ss', 'hh', 'ѕ', 's', 'h​', 's.'])); case 'unit-only': return one(rng.pick([...units, 'ms', 'hh', 'w0', 's1', 'h5'])); case 'number-syntax': return one(rng.pick(['1.5', '1.5h', '1e3', '1E3', '0x10', '0b1', '0o7', '1_000', '1,000', '1/2', '½', '10', '٣', '١٠m', 'Infinity', 'NaN', '10.', '.5', '1e', '1h30m', '10s5', '5d5'])); case 'two-args': return { cls, args: rng.pick([[digits(), rng.pick(units)], [digits(), digits()], ['10', 'hours'], ['last', '10'], ['', ''], [digits() + 'h', '--json']]) }; case 'empty': return one(''); case 'text': return one(fuzzText(rng, { maxLen: 256 }).value); } } // --------------------------------------------------------------------------------------------- // HTTP oracle (README "HTTP API" + Go's request parsing) // --------------------------------------------------------------------------------------------- /** One generated request. Header values/targets are JS strings sent as UTF-8 bytes on the raw socket. */ export interface HttpCase { cls: string; method: string; target: string; /** Header lines after Host (raw lines when `rawLines`, else name/value pairs). */ headers: Array<[string, string]>; /** Verbatim extra lines (used by malformed classes). */ rawLines?: string[]; body?: string; /** Present for malformed-by-construction cases: the Go-level statuses allowed. */ goReject?: number[]; } export interface HttpExpectation { /** Allowed statuses (exactly one for well-formed requests). */ statuses: number[]; /** Request reaches the application (logged as `this request has been made`). */ reachesApp: boolean; /** Token that gets an audit row (authenticated GET on the endpoint). */ audited?: string; /** Authentication class the app would log (only for GET on the endpoint). */ authClass?: string; } const TCHAR = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; export function isTokenChars(s: string): boolean { return TCHAR.test(s); } /** Percent-decodes a path (bytes); undefined for an invalid escape. */ export function percentDecode(s: string): Buffer | undefined { const bytes = Buffer.from(s, 'utf8'); const out: number[] = []; for (let i = 0; i < bytes.length; i++) { if (bytes[i] === 0x25) { const h = bytes.subarray(i + 1, i + 3).toString('latin1'); if (!/^[0-9A-Fa-f]{2}$/.test(h)) return undefined; out.push(parseInt(h, 16)); i += 2; } else out.push(bytes[i]); } return Buffer.from(out); } /** 'endpoint' | 'other' | 'bad' (Go rejects the request target with 400). */ export function routeOf(target: string): 'endpoint' | 'other' | 'bad' { // eslint-disable-next-line no-control-regex if (/[\x00-\x20\x7f]/.test(target)) return 'bad'; if (!target.startsWith('/')) return 'bad'; const q = target.indexOf('?'); const rawPath = q < 0 ? target : target.slice(0, q); const decoded = percentDecode(rawPath); if (!decoded) return 'bad'; if (decoded.equals(Buffer.from(ENDPOINT)) && !/%2f/i.test(rawPath)) return 'endpoint'; return 'other'; } /** Go's textproto trimming of header values (ASCII SP/HTAB only). */ export function goTrim(v: string): string { return v.replace(/^[ \t]+|[ \t]+$/g, ''); } /** The app's parseAuthorization + lookup on Go-trimmed header values. */ export function authOf(values: string[], lookup: (t: string) => 'active' | 'inactive' | undefined): { cls: string; token?: string } { if (values.length === 0) return { cls: 'missing_header' }; if (values.length > 1) return { cls: 'multiple_headers' }; const v = goTrim(values[0]); if (v === '') return { cls: 'empty_header' }; const sp = v.indexOf(' '); const scheme = sp < 0 ? v : v.slice(0, sp); const tok = sp < 0 ? '' : v.slice(sp + 1); if (!asciiEqualFold(scheme, 'Bearer')) return { cls: 'bad_scheme' }; if (tok === '') return { cls: 'empty_token' }; const st = lookup(tok); if (st === undefined) return { cls: 'unknown_token' }; if (st === 'inactive') return { cls: 'inactive_token' }; return { cls: 'ok', token: tok }; } /** Expected outcome of a generated request. `backendOk` false = the Docker backend fails (500 after audit). */ export function expectHttp(c: HttpCase, lookup: (t: string) => 'active' | 'inactive' | undefined, backendOk = true): HttpExpectation { if (c.goReject) return { statuses: c.goReject, reachesApp: false }; if (!isTokenChars(c.method)) return { statuses: [400], reachesApp: false }; const route = routeOf(c.target); if (route === 'bad') return { statuses: [400], reachesApp: false }; if (route === 'other') return { statuses: [404], reachesApp: true }; if (c.method !== 'GET') return { statuses: [405], reachesApp: true }; const auth = authOf( c.headers.filter(([n]) => n.toLowerCase() === 'authorization').map(([, v]) => v), lookup, ); if (auth.cls !== 'ok') return { statuses: [401], reachesApp: true, authClass: auth.cls }; return { statuses: [backendOk ? 200 : 500], reachesApp: true, audited: auth.token, authClass: 'ok' }; } /** Raw HTTP/1.1 bytes of a case (Host first, Connection: close last unless keepAlive). */ export function rawBytes(c: HttpCase, { keepAlive = false }: { keepAlive?: boolean } = {}): Buffer { const lines: string[] = [`${c.method} ${c.target} HTTP/1.1`, 'Host: localhost']; for (const [n, v] of c.headers) lines.push(`${n}: ${v}`); for (const l of c.rawLines ?? []) lines.push(l); if (c.body !== undefined) lines.push(`Content-Length: ${Buffer.byteLength(c.body)}`); if (!keepAlive) lines.push('Connection: close'); return Buffer.from(lines.join('\r\n') + '\r\n\r\n' + (c.body ?? ''), 'utf8'); } /** * Asserts a response against an expectation: exact app error contract for 401/404/405/500, * container listing for 200, plain-text Go response for Go-level rejections; and that no * registered secret appears anywhere in the bytes received. */ export function checkResponse( res: RawHttpResponse | HttpResult | CurlResult, exp: HttpExpectation, { head = false, containers, secrets, label }: { head?: boolean; containers?: number; secrets?: SecretRegistry; label: string }, ): void { const v = view(res); expect(exp.statuses, `${label}: status ${v.status} (body ${JSON.stringify(v.body.slice(0, 120))})`).toContain(v.status); if (exp.reachesApp) { if (v.status === 200) { if (!head) { const list = expectContainersResponse(v); if (containers !== undefined) expect(list.length, `${label}: container count`).toBe(containers); } } else expectErrorResponse(v, v.status as AppErrorStatus, { head }); } else { expect(v.headers['content-type'] ?? 'text/plain; charset=utf-8', `${label}: Go-level response is plain text`).toMatch(/^text\/plain/); } if (secrets) secrets.assertNoSecretsIn(JSON.stringify(v.headers) + '\n' + v.body, `${label}: response`); } /** The single response of a raw exchange (fails on any transport outcome other than a response). */ export function onlyResponse(r: RawResult, label: string): RawHttpResponse { expect(r.outcome, `${label}: transport outcome`).toBe('response'); expect(r.responses.length, `${label}: one response`).toBe(1); return r.response!; } // --------------------------------------------------------------------------------------------- // Request generators // --------------------------------------------------------------------------------------------- const STD_METHODS = ['GET', 'GET', 'GET', 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'TRACE', 'get', 'Get', 'GETS', 'FOO', 'M-SEARCH', 'PROPFIND']; const RESERVED_HEADERS = new Set(['host', 'content-length', 'transfer-encoding', 'connection', 'expect', 'upgrade', 'te', 'trailer', 'authorization', 'keep-alive', 'proxy-connection', 'http2-settings']); /** Endpoint-equivalent and look-alike request targets. */ export function fuzzTarget(rng: Rng): string { const r = rng.next(); const encodeSome = (p: string) => [...p] .map((c) => (/[A-Za-z0-9]/.test(c) && rng.bool(0.25) ? '%' + c.charCodeAt(0).toString(16)[rng.bool() ? 'toUpperCase' : 'toLowerCase']() : c)) .join(''); const query = () => '?' + fuzzQuery(rng); if (r < 0.45) return ENDPOINT + (rng.bool(0.4) ? query() : ''); if (r < 0.6) return encodeSome(ENDPOINT) + (rng.bool(0.3) ? query() : ''); if (r < 0.65) return '/api/v1' + rng.pick(['%2F', '%2f']) + 'containers'; if (r < 0.85) return rng.pick([ '/', '/api', '/api/v1', '/api/v1/', '/api/v1/containers/', '//api/v1/containers', '/api//v1/containers', '/api/v1/./containers', '/api/v1/../v1/containers', '/API/V1/CONTAINERS', '/api/v1/Containers', '/api/v1/containers.json', '/api/v1/containers/x', '/api/v1/containers;x', '/api/v1/containers%00', '/api/v1/containers%20', '/api/v1/containers%2e', '/api/v1/containers#frag', '/api/v1/conta%C3%AFners', '/api/v1/%252Fcontainers', '/api/v2/containers', '/api\\v1\\containers', '/containers', '/api/v1/cont\u00e4iners', '/' + 'a/'.repeat(rng.int(1, 200)), '/' + 'x'.repeat(rng.int(1, 4000)), ]) + (rng.bool(0.2) ? query() : ''); if (r < 0.93) return '/' + [...rng.bytes(rng.int(0, 40))].map((b) => (b > 0x20 && b < 0x7f && b !== 0x25 && b !== 0x3f && b !== 0x23 ? String.fromCharCode(b) : '%' + b.toString(16).padStart(2, '0'))).join(''); // invalid percent escapes / raw control chars → Go 400 return rng.pick([ENDPOINT + '%', ENDPOINT + '%zz', '/api/v1/%G0containers', '/api/v1/conta%2', ENDPOINT + '\x01', ENDPOINT + '\x7f', '/api/v1/cont\x1bainers']); } export function fuzzQuery(rng: Rng): string { return Array.from({ length: rng.int(0, 5) }, () => { const k = rng.pick(['all', 'token', 'access_token', 'limit', 'filters', '', rng.string(rng.int(1, 8))]); const v = rng.pick(['1', 'true', '%27%20OR%201%3D1', '%zz', '{"status":["exited"]}'.replace(/[{}":[\]]/g, (c) => '%' + c.charCodeAt(0).toString(16)), rng.string(rng.int(0, 30)), '..%2f..%2f', '%00', 'x'.repeat(rng.int(0, 2000))]); return `${k}=${v}`; }).join(rng.pick(['&', ';', '&&'])); } /** Header-safe fuzz value for raw sockets: no CTL other than HTAB; may contain non-ASCII (sent as UTF-8 obs-text). */ export function fuzzHeaderValue(rng: Rng): string { // eslint-disable-next-line no-control-regex return fuzzText(rng, { maxLen: 2048 }).value.replace(/[\x00-\x08\x0a-\x1f\x7f]/g, '').replace(/\u0085|\u2028/g, ''); } export function fuzzHeaderName(rng: Rng): string { let n = rng.pick(['X-Forwarded-For', 'Forwarded', 'X-Real-IP', 'Accept', 'User-Agent', 'Cookie', 'Content-Type', 'X-Api-Key', 'Proxy-Authorization', 'X-Auth-Token', 'Origin', 'Referer', 'If-None-Match', rng.string(rng.int(1, 30), "!#$%&'*+-.^_`|~abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")]); if (RESERVED_HEADERS.has(n.toLowerCase())) n = 'X-' + n; return n; } /** Authorization header values of every class, given the model's tokens. */ export function fuzzAuthorization(rng: Rng, model: StateModel): Array<[string, string]> { const active = model.active(); const inactive = model.inactive(); const any = model.all(); const name = () => rng.pick(['Authorization', 'Authorization', 'authorization', 'AUTHORIZATION', 'aUtHoRiZaTiOn']); const scheme = () => rng.pick(['Bearer', 'Bearer', 'bearer', 'BEARER', 'bEaReR']); const ows = () => rng.pick(['', '', '', ' ', '\t', ' ']); const r = rng.next(); if (r < 0.06) return []; if (r < 0.3 && active.length) return [[name(), `${ows()}${scheme()} ${rng.pick(active)}${ows()}`]]; if (r < 0.38 && inactive.length) return [[name(), `${scheme()} ${rng.pick(inactive)}`]]; if (r < 0.55 && any.length) return [[name(), `${scheme()} ${rng.pick(nearMatches(rng.pick(any), rng, 1).map((m) => m.value).filter((v) => !/[\n\r]/.test(v)).concat(['x']))}`]]; if (r < 0.62) return [[name(), `${scheme()} ${rng.string(43, TOKEN_CHARS)}`]]; if (r < 0.75) return [ [ name(), rng.pick([ '', 'Bearer', 'Bearer ', `Basic ${Buffer.from('user:pass').toString('base64')}`, `Token ${active[0] ?? 'x'}`, `${active[0] ?? 'x'}`, `Bearer ${active[0] ?? 'x'}`, `Bearer\t${active[0] ?? 'x'}`, `Bearer: ${active[0] ?? 'x'}`, `Bearer ${active[0] ?? 'x'} extra`, `Bearer "${active[0] ?? 'x'}"`, `Bearer <${active[0] ?? 'x'}>`, 'Bearer $TOKEN', `Bearer ${active[0] ?? 'x'}, Bearer ${active[0] ?? 'x'}`, `B\u0435arer ${active[0] ?? 'x'}`, `Bearer\u00a0${active[0] ?? 'x'}`, `BEARER ${(active[0] ?? 'x').toUpperCase()}`, ]), ], ]; if (r < 0.85 && any.length) { const n = rng.int(2, 3); return Array.from({ length: n }, () => [name(), `${scheme()} ${rng.pick(any)}`] as [string, string]); } return [[name(), fuzzHeaderValue(rng)]]; } /** A well-formed (by construction) request of a random class; the oracle decides its outcome. */ export function fuzzRequest(rng: Rng, model: StateModel): HttpCase { const m = rng.next(); const method = m < 0.55 ? 'GET' : m < 0.9 ? rng.pick(STD_METHODS) : rng.string(rng.int(1, 12), "!#$%&'*+-.^_`|~ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"); const headers: Array<[string, string]> = []; for (let i = rng.int(0, 4); i > 0; i--) headers.push([fuzzHeaderName(rng), fuzzHeaderValue(rng)]); const auth = fuzzAuthorization(rng, model); // interleave the Authorization lines among the other headers for (const a of auth) headers.splice(rng.int(0, headers.length), 0, a); const body = rng.bool(0.1) ? fuzzText(rng, { maxLen: 2048 }).value : undefined; return { cls: 'well-formed', method: method === 'CONNECT' || method === 'PRI' ? 'GET' : method, target: fuzzTarget(rng), headers, body }; } /** Malformed-by-construction requests with the statuses Go answers them with (never reach the app). */ export function malformedRequest(rng: Rng, model: StateModel): HttpCase { const tok = model.active()[0] ?? 'x'; const auth: [string, string] = ['Authorization', `Bearer ${tok}`]; const kinds: Array<() => HttpCase> = [ () => ({ cls: 'bad-method-char', method: rng.pick(['G@T', 'GE(T', 'G ET'.replace(' ', '"'), 'GET/', 'G\u00e9T', '{GET}']), target: ENDPOINT, headers: [auth] }), () => ({ cls: 'bad-header-name', method: 'GET', target: ENDPOINT, headers: [auth], rawLines: [rng.pick(['Bad Name: 1', 'Bad(Name): 1', 'B\u00e4d: 1', ': empty-name', 'X-A\u0001: 1'])] }), () => ({ cls: 'ctl-in-value', method: 'GET', target: ENDPOINT, headers: [], rawLines: [`Authorization: Bearer ${tok.slice(0, 10)}${String.fromCharCode(rng.pick([1, 8, 11, 12, 27, 127]))}${tok.slice(10)}`] }), () => ({ cls: 'cr-in-value', method: 'GET', target: ENDPOINT, headers: [], rawLines: [`Authorization: Bearer ${tok}\rX-Injected: 1`] }), () => ({ cls: 'space-before-colon', method: 'GET', target: ENDPOINT, headers: [], rawLines: [`Authorization : Bearer ${tok}`] }), () => ({ cls: 'no-colon', method: 'GET', target: ENDPOINT, headers: [auth], rawLines: ['NoColonHere'] }), () => ({ cls: 'bad-percent', method: 'GET', target: rng.pick([ENDPOINT + '%', '/api/v1/%zzcontainers', '/api%2']), headers: [auth] }), () => ({ cls: 'ctl-in-target', method: 'GET', target: ENDPOINT + String.fromCharCode(rng.pick([1, 9, 27, 127])), headers: [auth] }), () => ({ cls: 'duplicate-host', method: 'GET', target: ENDPOINT, headers: [auth], rawLines: ['Host: other'] }), () => ({ cls: 'too-many-headers', method: 'GET', target: ENDPOINT, headers: [auth], rawLines: Array.from({ length: rng.int(510, 700) }, (_, i) => `X-H${i}: v`), goReject: [431] }), () => ({ cls: 'oversized-header', method: 'GET', target: ENDPOINT, headers: [auth], rawLines: [`X-Big: ${'b'.repeat(rng.int(1100, 1400) * 1024)}`], goReject: [431] }), () => ({ cls: 'unsupported-transfer-encoding', method: 'GET', target: ENDPOINT, headers: [auth], rawLines: [`Transfer-Encoding: ${rng.pick(['gzip', 'identity', 'chunked, gzip', 'x'])}`], goReject: [501] }), () => ({ cls: 'conflicting-content-length', method: 'GET', target: ENDPOINT, headers: [auth], rawLines: ['Content-Length: 1', 'Content-Length: 2'] }), ]; const c = rng.pick(kinds)(); return { ...c, goReject: c.goReject ?? [400] }; }