Files
dpsapi/e2e/harness/personas.ts
T
bryanpedini fe0cd4bf6d test(e2e): add playwright e2e harness
Process and API-only Playwright harness: binary and image setup, server/CLI runners, fake Docker Engine socket, read-only database inspection, secret redaction in reports, seeded fuzzing, leak detection and harness self-checks.
2026-10-06 10:52:25 +02:00

1096 lines
53 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, ModelToken>();
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<string, number>();
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<Record<string, unknown>>).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<string, string> = {
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<ConfigOutcome, { ok: true }>): 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 <args>` 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<unit>]")
// ---------------------------------------------------------------------------------------------
export const MAX_INT64 = 9223372036854775807n;
const NS_PER_SECOND = 1_000_000_000n;
/** Window units (lowercase only) in nanoseconds. */
export const AUDIT_UNITS: Readonly<Record<'s' | 'm' | 'h' | 'd' | 'w', bigint>> = {
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<Record<keyof typeof AUDIT_UNITS, bigint>> = {
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<T extends { rowid: number; time: string }>(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 <window>` 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<keyof typeof AUDIT_UNITS>;
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<AuditArgClass>(['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] };
}