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