File size: 6,989 Bytes
f0634fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | // test/fuzz.test.ts
//
// Deterministic fuzzing (fixed-seed PRNG — no per-run randomness; scale the
// volume with TS_BASH_FUZZ_SCALE=N for deeper local runs):
// a) token soup — random sequences of bash operators/keywords/fragments;
// b) byte mutations of the curated fixtures (replace/delete/insert,
// NUL included);
// c) nesting bombs — programmatic deep $(…) / (…) / ${…} / if nesting.
//
// Contract under test: parse never throws; within budget it returns either
// ok:true or { ok:false, reason:'aborted' }; an ok:true tree is always
// structurally sound (assertTreeIntegrity); nesting beyond the documented
// depth caps degrades locally (hasError) instead of throwing.
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { parse } from '#/parse';
import type { ParseOptions, ParseResult } from '#/parse';
import { assertTreeIntegrity, parseFixtureFile } from './helpers/differential';
const PKG_ROOT = path.resolve(import.meta.dirname, '..');
const SCALE = Math.max(1, Number(process.env['TS_BASH_FUZZ_SCALE'] ?? 1) || 1);
/** Park–Miller minimal standard PRNG: small, portable, deterministic. */
function prng(seed: number): () => number {
let state = seed % 2147483647;
if (state <= 0) state += 2147483646;
return () => {
state = (state * 16807) % 2147483647;
return (state - 1) / 2147483646;
};
}
function pick(rand: () => number, pool: readonly string[]): string {
return pool[Math.floor(rand() * pool.length)]!;
}
function checkContract(source: string, options?: ParseOptions): ParseResult {
let result: ParseResult | undefined;
expect(() => {
result = parse(source, options);
}).not.toThrow();
expect(result).toBeDefined();
if (result!.ok) {
assertTreeIntegrity(result!.rootNode, source);
} else {
expect(result!).toEqual({ ok: false, reason: 'aborted' });
}
return result!;
}
const TOKEN_POOL = [
'echo', 'ls', 'foo', 'bar', 'x', 'A=1', 'if', 'then', 'fi', 'while', 'do', 'done', 'for', 'in', 'case', 'esac',
'&&', '||', '|', '|&', ';', '&', ';;', ';&', '(', ')', '{', '}', '>', '>>', '<', '>&1', '2>', '&>', '<<EOF',
'<<<', '$x', '${v}', '${v:-d}', '$(cmd)', '`cmd`', '$((1+2))', '[[', ']]', '[', ']', '==', '=~', '-f', '-z',
'"str"', "'raw'", '$"t"', "$'a'", '*', '?', '[a-z]', '@(a|b)', '\\\\', '\\n', '\n', '#c', 'function', 'return',
'${v#p}', '${v/p/r}', '${v[@]}', '!<', '>(p)', '<(p)', '$#', '$?', '0x1F', '..', '<<-', '>&-', 'abc_def',
] as const;
function fixtureSources(): string[] {
const dir = path.join(PKG_ROOT, 'test/fixtures/differential');
const out: string[] = [];
for (const file of readdirSync(dir).filter((f) => f.endsWith('.txt')).toSorted()) {
for (const sample of parseFixtureFile(file, readFileSync(path.join(dir, file), 'utf8'))) {
out.push(sample.source);
}
}
return out;
}
describe('fuzz: token soup', () => {
it('never throws and always yields a sound tree or a clean abort', () => {
const rand = prng(0x5eed0001);
const count = 250 * SCALE;
let parsed = 0;
for (let n = 0; n < count; n++) {
const length = 3 + Math.floor(rand() * 22);
const parts: string[] = [];
for (let k = 0; k < length; k++) parts.push(pick(rand, TOKEN_POOL));
if (checkContract(parts.join(' ')).ok) parsed++;
}
expect(parsed).toBeGreaterThan(0);
console.log(`token soup: ${count} inputs, ${parsed} parsed / ${count - parsed} aborted`);
});
});
describe('fuzz: byte mutations of fixtures', () => {
it('never throws and always yields a sound tree or a clean abort', () => {
const rand = prng(0x5eed0002);
const bases = fixtureSources();
const count = 300 * SCALE;
let parsed = 0;
for (let n = 0; n < count; n++) {
const base = pick(rand, bases);
if (base.length === 0) continue;
const pos = Math.floor(rand() * base.length);
const mode = rand();
let mutated: string;
if (mode < 0.4) {
// replace one code unit (may be NUL)
const code = Math.floor(rand() * 256);
mutated = base.slice(0, pos) + String.fromCodePoint(code) + base.slice(pos + 1);
} else if (mode < 0.7) {
mutated = base.slice(0, pos) + base.slice(pos + 1);
} else {
const code = Math.floor(rand() * 256);
mutated = base.slice(0, pos) + String.fromCodePoint(code) + base.slice(pos);
}
if (checkContract(mutated).ok) parsed++;
}
expect(parsed).toBeGreaterThan(0);
console.log(`byte mutations: ${count} inputs, ${parsed} parsed / ${count - parsed} aborted`);
});
});
describe('fuzz: nesting bombs degrade locally per the documented depth caps', () => {
const substitution = (depth: number): string => `echo ${'$('.repeat(depth)}x${')'.repeat(depth)}`;
const subshell = (depth: number): string => `${'('.repeat(depth)}x${')'.repeat(depth)}`;
const expansion = (depth: number): string => `echo ${'${a:-'.repeat(depth)}z${'}'.repeat(depth)}`;
const ifs = (depth: number): string => `${'if x; then '.repeat(depth)}y${'; fi'.repeat(depth)}`;
it('within the caps the trees are clean', () => {
// literalDepth ticks twice per ${…} level (parseLiteral + parseExpansion),
// so 200 levels stay under MAX_PARSE_DEPTH = 500.
for (const source of [substitution(100), subshell(400), expansion(200), ifs(400)]) {
const result = checkContract(source, { timeoutMs: 60_000 });
expect(result.ok).toBe(true);
if (result.ok) expect(result.hasError).toBe(false);
}
});
it('beyond the caps the parse still succeeds and flags hasError (MAX_SUBSTITUTION_DEPTH = 150)', () => {
const result = checkContract(substitution(200));
expect(result.ok).toBe(true);
if (result.ok) expect(result.hasError).toBe(true);
});
it('beyond the caps the parse still succeeds and flags hasError (MAX_PARSE_DEPTH = 500)', () => {
for (const source of [subshell(600), expansion(600), ifs(600)]) {
const result = checkContract(source, { timeoutMs: 60_000 });
expect(result.ok).toBe(true);
if (result.ok) expect(result.hasError).toBe(true);
}
});
it('extreme nesting never overflows the stack', () => {
for (const source of [substitution(5000), subshell(5000), expansion(5000), ifs(5000)]) {
const result = checkContract(source, { timeoutMs: 60_000 });
// Either a locally degraded tree (hasError) or a clean budget abort.
if (result.ok) expect(result.hasError).toBe(true);
else expect(result.reason).toBe('aborted');
}
});
it('the node budget aborts huge flat programs, and a raised budget parses them', () => {
const source = 'echo a; '.repeat(20_000);
expect(parse(source)).toEqual({ ok: false, reason: 'aborted' });
const result = parse(source, { timeoutMs: 60_000, maxNodes: 10_000_000 });
expect(result.ok).toBe(true);
if (result.ok) assertTreeIntegrity(result.rootNode, source);
});
});
|