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.
1096 lines
53 KiB
TypeScript
1096 lines
53 KiB
TypeScript
// 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] };
|
||
}
|