// Shell Expansion, checked against real dash and bash, and then broken on purpose. // // The independent oracle is the shells themselves. scripts/shell-expansion-oracle.py // ran every line below through dash -c, bash -c and bash --posix -c in a scratch // directory holding the same nine files the page uses, with a probe standing in // for each command that wrote down its argv and its descriptor table, and saved // what happened in tests/fixtures/shell-expansion-oracle.json. This file runs the // page's engine on the same lines and compares, in order, with its own // normaliser rather than the engine's. // // A second route that is not the shells: the engine's printed system calls are // replayed here on a fresh table and must give the table the engine reports, and // the strace recordings of the same lines must show the same opens and dup2s. import { readFileSync, existsSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..'); let p = 0; let MUTANTS = 0; let f = 0; const check = (label, ok, detail = '') => { if (ok) { p += 1; console.log(` ok ${label}${detail ? ` [${detail}]` : ''}`); } else { f += 1; console.error(` FAIL ${label}${detail ? `\n ${detail}` : ''}`); } }; const ENGINE_SRC = readFileSync(join(ROOT, 'site/js/shell-expansion.js'), 'utf8'); function engine(src = ENGINE_SRC) { const window = {}; new Function('window', src)(window); return window.ShellExpansion; } const S = engine(); const F = JSON.parse(readFileSync(join(ROOT, 'tests/fixtures/shell-expansion-oracle.json'), 'utf8')); const X = JSON.parse(readFileSync(join(ROOT, 'tests/fixtures/shell-expansion-extra.json'), 'utf8')); const PIPE = JSON.parse(readFileSync(join(ROOT, 'tests/fixtures/shell-expansion-pipe-atomicity.json'), 'utf8')); const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); /* ---- the engine's own claims ------------------------------------------------ */ { const rows = S.verify(); check('the page makes at least twelve checks at load', rows.length >= 12, String(rows.length)); rows.forEach((n) => check(`load-time: ${n.claim.slice(0, 70)}`, n.ok && !n.untested, n.detail)); } /* ---- the world is the one the shells were run in ---------------------------- */ { check('the page directory is the oracle directory, in order', same(S.FILES, F.files)); check('the page commands are the oracle commands', same(S.COMMANDS, F.commands)); check('the page builtins are the oracle builtins', same(S.BUILTINS, F.builtins)); check('every page variable has the value the shells were given', Object.keys(S.DEFAULT_VARS).every((k) => F.vars[k] === S.DEFAULT_VARS[k]), Object.keys(S.DEFAULT_VARS).filter((k) => F.vars[k] !== S.DEFAULT_VARS[k]).join(', ')); for (const shell of F.shells) { const names = F.names[shell]; check(`${shell}: none of the page's commands is built in`, S.COMMANDS.every((c) => names[c] === 'external'), S.COMMANDS.filter((c) => names[c] !== 'external').join(', ')); check(`${shell}: every name the page refuses as built in is built in`, S.BUILTINS.every((c) => names[c] === 'builtin'), S.BUILTINS.filter((c) => names[c] !== 'builtin').join(', ')); } // Recorded versions, as the page prints them. check('the recording is of dash 0.5.12-12ubuntu3', F.versions.dash === '0.5.12-12ubuntu3', F.versions.dash); check('and GNU bash 5.3.9(1)-release', /version 5\.3\.9\(1\)-release/.test(F.versions.bash), F.versions.bash); check('on Linux 7.0.0-31-generic, on 2026-10-07, with LC_ALL=C', F.versions.kernel === '7.0.0-31-generic' && F.versions.date === '2026-10-07' && F.versions.locale === 'LC_ALL=C'); check('and the engine says the same versions it was recorded with', S.RECORDED.versions.dash === F.versions.dash && F.versions.bash.includes(S.RECORDED.versions.bash) && S.RECORDED.versions.kernel === F.versions.kernel && S.RECORDED.versions.date === F.versions.date); } /* ---- the sweep: every line, every shell, outputs in order -------------------- */ // This normaliser is written here, not imported, so a fault in the engine's // own summary() cannot hide a fault in its model. function normalise(res) { const pipes = new Map(); const name = (d) => { if (d.kind === 'tty') return `inherited ${d.origin}`; if (d.kind === 'null') return '/dev/null'; if (d.kind === 'pipe') { if (!pipes.has(d.pipe)) pipes.set(d.pipe, pipes.size + 1); return `pipe ${pipes.get(d.pipe)}`; } return `file ${d.name}`; }; const procs = res.members.filter((m) => m.exec).map((m) => ({ argv: m.argv, fds: m.fds.map((x) => [x.fd, name(x.desc), x.desc.mode, x.desc.append, x.shares]), })); // A failed redirection's complaint goes to whatever fd 2 is at that moment. // The recording sees it on the terminal's stderr, or as bytes in a file. const failed = res.members.filter((m) => m.error); const onTerminal = failed.filter((m) => m.error.to && m.error.to.desc.kind === 'tty' && m.error.to.desc.origin === 2).map((m) => m.error.kind).sort(); const intoFile = new Set(failed.filter((m) => m.error.to && m.error.to.desc.kind === 'file' && m.error.to.desc.mode === 'w').map((m) => m.error.to.desc.name)); const changed = res.dir.map((d) => [d.name, intoFile.has(d.name) ? 'written' : 0]); for (const n of intoFile) if (!changed.some(([x]) => x === n)) changed.push([n, 'written']); changed.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); return { procs, changed, errors: onTerminal }; } function recorded(r) { return { procs: r.procs, changed: r.changed.map(([n, z]) => [n, z > 0 ? 'written' : 0]), errors: [...r.errors].sort() }; } const BRACE = /unquoted brace/; const RACE = /another command in this pipeline creates|created by another/; { let compared = 0; let differ = 0; let handRefused = 0; const refusedWhy = { brace: 0, race: 0, other: [] }; const firstDiffs = []; let withPipes = 0; let withErrors = 0; let created = 0; for (const c of F.cases) { for (const shell of F.shells) { const want = c.shells[shell]; check.silent = true; const res = S.run(c.line, F.vars, shell); if (!res.ok) { if (BRACE.test(res.refused.reason)) refusedWhy.brace += 1; else if (RACE.test(res.refused.reason)) refusedWhy.race += 1; else refusedWhy.other.push(`${shell}: ${c.line}: ${res.refused.reason.slice(0, 60)}`); if (c.kind === 'hand') handRefused += 1; continue; } // A file the page reports as created or emptied must be in the directory, // and nothing may vanish from it: the probe deletes nothing. if (want.missing.length) refusedWhy.other.push(`${shell}: ${c.line}: a file went missing`); compared += 1; if (res.members.length > 1) withPipes += 1; if (res.members.some((m) => m.error)) withErrors += 1; if (res.dir.length) created += 1; if (!same(normalise(res), recorded(want))) { differ += 1; if (firstDiffs.length < 5) { firstDiffs.push(`${shell}: ${c.line}\n page ${JSON.stringify(normalise(res))}` + `\n shell ${JSON.stringify(recorded(want))}`); } } } } const total = F.cases.length * F.shells.length; check(`the sweep covers ${F.cases.length} lines in ${F.shells.length} shells`, F.cases.length >= 600 && F.shells.length === 3, String(total)); check('the engine agrees with the real shells on every line it accepts', differ === 0 && compared > 0, `${compared} compared, ${differ} differ${firstDiffs.length ? '\n ' + firstDiffs.join('\n ') : ''}`); check('and it accepts nearly all of them', compared >= 0.95 * total, `${compared} of ${total}`); // What it cannot tell apart, printed rather than hidden. console.log(` note ${total - compared} runs refused: ${refusedWhy.brace} for a brace in bash, ` + `${refusedWhy.race} for a pipeline race; ${withPipes} compared runs were pipelines, ` + `${withErrors} had a failing redirection, ${created} created or emptied a file`); check('every refusal is one of the two kinds the ledger names', refusedWhy.other.length === 0, refusedWhy.other.slice(0, 3).join(' | ')); check('the only hand-written line refused is the brace, in the two bash modes', handRefused === 2, String(handRefused)); check('the comparison reached pipelines, failures and files, not just plain lines', withPipes >= 100 && withErrors >= 100 && created >= 100, `${withPipes}, ${withErrors}, ${created}`); } /* ---- the strace recordings: open and dup2, in order -------------------------- */ // A two-command pipeline's pipe is the two lowest free descriptors in a shell // holding 0, 1 and 2, so its read end is 3 and its write end 4. function traceDiffs(E) { const bad = []; for (const t of F.traces) { const res = E.run(t.line, F.vars, t.shell); const calls = res.members.map((m) => m.calls.flatMap((c) => { if (c.call === 'open') return [`open ${c.name} ${c.flags} = ${c.result}`]; if (c.call === 'dup2' && !c.failed) { const from = c.from === 'pipe 1 read end' ? 3 : c.from === 'pipe 1 write end' ? 4 : c.from; return [`dup2 ${from} ${c.to}`]; } return []; })).filter((x) => x.length); if (!same(calls, t.procs)) bad.push(`${t.shell} ${t.line}: page ${JSON.stringify(calls)} strace ${JSON.stringify(t.procs)}`); } return bad; } { const bad = traceDiffs(S); check('the engine makes the opens and dup2s strace recorded, in the same order', F.traces.length >= 24 && bad.length === 0, bad.length ? bad[0] : `${F.traces.length} traces`); check('strace: with 0 closed, open returns 0, the lowest free descriptor', same(F.traces.find((t) => t.line === 'ls <&- >o' && t.shell === 'bash').procs[0], ['open o O_WRONLY|O_CREAT|O_TRUNC = 0', 'dup2 0 1'])); const order = (line) => F.traces.find((t) => t.line === line && t.shell === 'dash').procs[0]; check('strace: >o 2>&1 opens o, then dup2(3, 1), then dup2(1, 2)', same(order('ls >o 2>&1'), ['open o O_WRONLY|O_CREAT|O_TRUNC = 3', 'dup2 3 1', 'dup2 1 2'])); check('strace: 2>&1 >o does dup2(1, 2) before it opens o', same(order('ls 2>&1 >o'), ['dup2 1 2', 'open o O_WRONLY|O_CREAT|O_TRUNC = 3', 'dup2 3 1'])); } /* ---- a second route to every table: replay the printed calls ---------------- */ { let n = 0; const bad = []; for (const c of F.cases.slice(0, 300)) { for (const shell of F.shells) { const res = S.run(c.line, F.vars, shell); if (!res.ok) continue; for (const m of res.members) { if (m.error) continue; // Replayed here, by a different reading of the same text. const t = new Map([[0, "terminal (the shell's fd 0)"], [1, "terminal (the shell's fd 1)"], [2, "terminal (the shell's fd 2)"]]); for (const call of m.calls) { const s = call.text; let x; if ((x = s.match(/^dup2\(pipe (\d+) (read|write) end, (\d)\)$/))) t.set(Number(x[3]), `pipe ${x[1]}, ${x[2]} end`); else if ((x = s.match(/^open\("(.*)", [A-Z_|]+\) = (\d+)$/))) t.set(Number(x[2]), x[1]); else if ((x = s.match(/^dup2\((\d+), (\d+)\)$/))) { if (t.has(Number(x[1]))) t.set(Number(x[2]), t.get(Number(x[1]))); } else if ((x = s.match(/^close\((\d+)\)$/))) t.delete(Number(x[1])); } const mine = new Map(m.fds.map((d) => [d.fd, d.label])); n += 1; if (!same([...t].sort((a, b) => a[0] - b[0]), [...mine])) bad.push(`${shell}: ${c.line}`); } } } check('replaying the printed calls gives the printed table', n > 500 && bad.length === 0, bad.length ? `${bad.length} differ, first ${bad[0]}` : `${n} tables`); } /* ---- the recorded outcomes the page carries ---------------------------------- */ { const derived = []; for (const line of S.PRESETS) { const c = F.cases.find((x) => x.line === line); for (const shell of F.shells) { const r = c.shells[shell]; derived.push({ line, shell, want: { procs: r.procs.map((q) => ({ argv: q.argv, fds: q.fds.map((d) => `${d[0]} ${d[1]} ${d[4]}`) })), changed: r.changed.map((x) => x[0]).sort(), errors: [...r.errors].sort() } }); } } check('the outcomes the page carries are the fixture\'s, for every preset in every shell', same(S.RECORDED.cases, derived), `${S.RECORDED.cases.length} carried, ${derived.length} derived`); check('which is thirty of them', S.RECORDED.cases.length === 30 && S.PRESETS.length * 3 === 30); } /* ---- the witnesses, read from the recording, not the engine ------------------ */ { const rec = (line, shell) => F.cases.find((c) => c.line === line).shells[shell]; const g = rec('ls $g', 'dash').procs[0].argv; const txt = F.files.filter((x) => /\.txt$/.test(x) && x[0] !== '.'); check('dash: ls $g hands ls every .txt file twice', same(g.slice(1), txt.concat(txt)), g.join(' | ')); check('bash: the same', same(rec('ls $g', 'bash').procs[0].argv, g)); check('dash: ls > *.xyz creates a file named *.xyz', rec('ls > *.xyz', 'dash').changed.some(([n]) => n === '*.xyz')); check('bash: ls > *.xyz is an ambiguous redirect and runs nothing', rec('ls > *.xyz', 'bash').errors.includes('ambiguous') && rec('ls > *.xyz', 'bash').procs.length === 0); check('bash --posix: ls > *.xyz creates the file, as dash does', rec('ls > *.xyz', 'posix').changed.some(([n]) => n === '*.xyz')); check('bash: ls > R* empties README', same(rec('ls > R*', 'bash').changed, [['README', 0]])); check('dash: ls >o 2>&1 leaves 2 sharing 1\'s open file', same(rec('ls >o 2>&1', 'dash').procs[0].fds[2], [2, 'file o', 'w', false, 1])); check('dash: ls 2>&1 >o leaves 2 on the shell\'s fd 1', same(rec('ls 2>&1 >o', 'dash').procs[0].fds[2], [2, 'inherited 1', 'w', false, 2])); check('every shell: in ls 2>&1 | wc, ls complains into the pipe', F.shells.every((s) => same(rec('ls 2>&1 | wc', s).procs[0].fds[2], [2, 'pipe 1', 'w', false, 1]))); check('dash: [^a].txt matches a.txt, where bash reads it as not-a', same(rec('ls [^a].txt', 'dash').procs[0].argv, ['ls', 'a.txt']) && same(rec('ls [^a].txt', 'bash').procs[0].argv, ['ls', 'b.txt'])); check('dash: .* reaches . and .., bash: it does not', rec('ls .*', 'dash').procs[0].argv.includes('..') && !rec('ls .*', 'bash').procs[0].argv.includes('..')); check('dash loses the complaint when 2>&5 fails, bash prints it', rec('ls >o 2>&5', 'dash').stderr.length === 0 && rec('ls >o 2>&5', 'bash').errors.includes('badfd')); check('and a complaint goes into a file when fd 2 already is one', F.shells.every((s) => rec('ls 2>o 2>&7', s).changed.some(([n, z]) => n === 'o' && z > 0))); } /* ---- refusals: every one the ledger names, refused with its reason ----------- */ { const refused = (line, shell = 'dash', vars = S.DEFAULT_VARS) => { const r = S.run(line, vars, shell); return r.ok ? null : r.refused.reason; }; const cases = [ ['ls ~', /tilde/], ['ls $(ls)', /\$\( \)/], ['ls `ls`', /Backquotes/], ['ls $((1+1))', /arithmetic/], ['ls ${g:-x}', /\$name and \$\{name\}/], ['ls $@', /special/], ['ls $1', /special/], ['cat <o', /<>/], ['ls >|o', />\|/], ['ls; ls', /list/], ['ls && ls', /background|list/], ['ls || ls', /\|\|/], ['(ls)', /subshell/], ['x=1 ls', /assigns/], ['if ls', /reserved/], ['echo hi', /built into/], ['vi a.txt', /not one of the commands/], ['ls 12>o', /above 9/], ['ls >&o', /single descriptor digit/], ['ls > dir/x', /slash/], ['ls d/*', /slash/], ['ls > .', /directory/], ['ls "a', /no partner/], ['ls #c', /comment/], ['ls |', /no command/], ['ls >', /nothing after/], ['ls é', /ASCII/], ['', /no command/i], // A backslash as the very last character: no next line to continue onto. ['ls a\\', /there is no next/], // A target whose slash is its first character, the absolute path. ['ls > /tmp/o', /slash/], // Special parameters other than $@ and $1, which kept the "bang" mutant alive. ['ls $!', /special/], ['ls $$', /special/], ['ls $-', /special/], ['ls $?', /special/], ['ls [[:alpha:]]*', /character class/], ['ls $PWD', /sets it for itself/], ]; for (const [line, why] of cases) { const r = refused(line); check(`refused: ${JSON.stringify(line)}`, r !== null && why.test(r), r || 'accepted'); } check('refused in bash: an unquoted brace', BRACE.test(refused('ls {a,b}.txt', 'bash') || '')); check('and in dash the brace is ordinary', refused('ls {a,b}.txt', 'dash') === null && same(S.run('ls {a,b}.txt', S.DEFAULT_VARS, 'dash').members[0].argv, ['ls', '{a,b}.txt'])); check('refused: a backslash in a value', /backslash/.test(refused('ls $g', 'dash', { g: 'a\\*' }) || '')); check('refused: a pipeline that reads a file another member creates', RACE.test(refused('ls >new | cat n + X.shells.filter((s) => (typeof c.page === 'string' ? c.page : c.page[s]) === 'agree').length, 0); check('the red team\'s lines: the page agrees with the shell or refuses by name, in every shell', X.cases.length >= 60 && bad.length === 0, bad.length ? `${bad.length} wrong, first ${bad.slice(0, 3).join(' | ')}` : `${X.cases.length} lines, ${agreed} runs compared, the rest refused`); check('they were recorded from the same dash and bash as the sweep', X.versions.dash === F.versions.dash && X.versions.bash === F.versions.bash, `${X.versions.dash}; ${X.versions.bash}`); // The names each shell sets for itself, as the shells said. for (const shell of X.shells) { const want = X.set_at_start[shell].filter((n) => n !== 'IFS' && n !== 'PROBE_OUT'); check(`${shell}: the page refuses every name the shell sets at start, and only those`, same([...S.SET_AT_START[shell]].sort(), want.sort()), `page ${S.SET_AT_START[shell].length}, shell ${want.length}`); check(`${shell}: IFS is set at start, and the page's $IFS is space, tab and newline`, X.set_at_start[shell].includes('IFS') && S.BLANKS === ' \t\n' && same(X.cases.find((c) => c.line === 'ls "$IFS"').shells[shell].procs[0].argv, ['ls', S.BLANKS])); } check('the probe asked about more names than any shell set, so a missing name would show', X.shells.every((s) => X.candidates.length > X.set_at_start[s].length)); } /* ---- the briefs: a right answer passes, a wrong one is told why -------------- */ { const brief = (id, line, shell = 'dash') => S.checkBrief(id, S.run(line, S.DEFAULT_VARS, shell)); const yes = [['errors-to-log', 'ls 2>log'], ['errors-to-log', 'ls 2>>log'], ['one-argument', 'ls "$g"'], ['one-argument', 'ls "${g}"'], ['both-into-wc', 'ls 2>&1 | wc'], ['both-into-wc', 'ls 2>&1 | wc', 'bash']]; for (const [id, line, sh] of yes) check(`brief ${id}: ${line} is accepted`, brief(id, line, sh).ok, brief(id, line, sh).said); const no = [['errors-to-log', 'ls >log 2>&1', /Descriptor 1/], ['errors-to-log', 'ls 2>&1 >log', /Descriptor 2/], ['errors-to-log', 'ls', /Descriptor 2/], ['one-argument', 'ls $g', /6 arguments/], ['one-argument', "ls '*.txt *.txt'", /\$g/], ['both-into-wc', 'ls | wc', /complains/], ['both-into-wc', 'ls 2>&1 >o | wc', /prints to o/], ['both-into-wc', 'ls', /two commands/], ['both-into-wc', 'ls >&2 2>&1 | wc', /prints to terminal/]]; for (const [id, line, why] of no) { const r = brief(id, line); check(`brief ${id}: ${line} is refused, and says why`, !r.ok && why.test(r.said), r.said); } check('each brief states a goal, not the answer', S.BRIEFS.every((b) => !/2>&1|2>log|"\$g"|\| wc/.test(b.ask))); } /* ---- the pipe measurement the ledger prints --------------------------------- */ { const row = (size) => PIPE.rows.find((r) => r.size === size); check('measured on Linux 7.0.0-31-generic, PIPE_BUF 4096, capacity 65536', PIPE.kernel === '7.0.0-31-generic' && PIPE.pipe_buf === 4096 && PIPE.capacity === 65536); check('four writers, 200 rounds, 800 writes per size', PIPE.rows.every((r) => r.writers === 4 && r.rounds === 200 && r.writes === 800)); check('none torn at 512, 4,096, 4,097 or 65,536 bytes', [512, 4096, 4097, 65536].every((s) => row(s) && row(s).torn_writes === 0)); check('470 of 800 torn at 65,537 bytes, in 188 rounds', row(65537).torn_writes === 470 && row(65537).torn_rounds === 188); check('785 of 800 torn at 200,000 bytes', row(200000).torn_writes === 785); } /* ---- what the page ships ----------------------------------------------------- */ const PAGE = join(ROOT, 'site/nibbles/shell-expansion/index.html'); const SPEC = JSON.parse(readFileSync(join(ROOT, 'machines/shell-expansion.json'), 'utf8')); const flat = (h) => h.replace(/<[^>]+>/g, ' ').replace(/“|”/g, '"').replace(/’/g, "'") .replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&').replace(/\s+/g, ' '); { if (!existsSync(PAGE)) { check('the page exists', false, 'site/nibbles/shell-expansion/index.html is missing: scaffold it'); } else { const page = flat(readFileSync(PAGE, 'utf8')); // Every figure in the ledger, held against the measurement or the recording. const ledger = flat(SPEC.ledger.map((x) => x.body).join(' ')); for (const fig of ['512', '4,096', '4,097', '65,536', '65,537', '200,000', '800', '470', '188', '785']) { check(`the page states ${fig}`, page.includes(fig)); } check('and the second run\'s 468 and 781 are the ones in the handoff record', ledger.includes('468') && ledger.includes('781')); check('the page names the shells by the versions recorded', page.includes('dash 0.5.12-12ubuntu3') && page.includes('GNU bash 5.3.9(1)-release') && page.includes('Linux 7.0.0-31-generic')); check('and the date, 7 October 2026', page.includes('7 October 2026')); check('"thirty of the outcomes" is ten presets in three shells', page.includes('thirty of the outcomes') && S.PRESETS.length * 3 === 30); check('"the nine files" is the directory', page.includes('nine files') && S.FILES.length === 9); check('"five places" is the five settings that differ, besides braces', page.includes('five places') && (() => { const keys = Object.keys(S.SHELLS.dash).filter((k) => !['name', 'says', 'braces'].includes(k)); return keys.length === 5 && keys.every((k) => S.SHELLS.dash[k] !== S.SHELLS.bash[k] || S.SHELLS.dash[k] !== S.SHELLS.posix[k] || S.SHELLS.bash[k] !== S.SHELLS.posix[k]); })()); check('POSIX\'s 2024 edition, the one cited', page.includes('2024') && /9799919799/.test(readFileSync(PAGE, 'utf8'))); check('the year is 1979 and the month January', page.includes('1979') && page.includes('January')); // Superlatives, proved before they are believed. const firsts = (t) => /\b(was|is) the first\b|\binvented\b|\bthe only shell\b/i.test(t); check('the superlative check sees one when it is made', firsts('The Bourne shell was the first to glob.')); check('and the page makes none', !firsts(page.replace(/nothing here says who first did any of it/, ''))); check('the page points at Copy-on-Write, System Call, Regex and Inode', ['/nibbles/copy-on-write/', '/nibbles/system-call/', '/nibbles/regex/', '/nibbles/inode/'] .every((u) => readFileSync(PAGE, 'utf8').includes(`href="${u}"`))); check('every stage heading is the roster entry\'s, in order', (() => { const roster = JSON.parse(readFileSync(join(ROOT, 'session-notes/roster-shell-expansion.json'), 'utf8')); const html = readFileSync(PAGE, 'utf8'); const got = [...html.matchAll(/\d<\/span> ([^<]+)<\/p>/g)].map((m) => m[1] .replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'")); return same(got, roster.stages); })()); check('the sound verdict is on the page', /Sound: no/.test(page)); } } /* ---- outside-review findings, held against the spec ------------------------- */ // Each of these was a sentence that said more than the engine or the shells do. // They read the spec, so they hold before the page is rebuilt from it. { const lede = flat(SPEC.lede); const hints = flat(SPEC.panels.join(' ')); const ledger = flat(SPEC.ledger.map((x) => x.body).join(' ')); const handed = S.run('ls $g', null, 'dash').members[0].argv.slice(1); const txt = S.FILES.filter((n) => n.charAt(0) !== '.' && /\.txt$/.test(n)); check('the lede says every .txt file is handed over twice, which is what ls $g gives', /every \.txt file twice/.test(lede) && !/every file is listed twice/.test(lede) && same(handed, txt.concat(txt)), JSON.stringify(handed)); check('the lede does not say no program sees the line (the shell is one)', !/No program ever sees/.test(lede)); check('the lede cuts only unquoted values, and "$g" is not cut', /cut the unquoted values/.test(lede) && S.run('ls "$g"', null, 'dash').members[0].argv.length === 2); const target = (s) => S.run('ls > $x', null, s); check('a target is not said to go through the same stages, since dash expands $x and does not glob it', !/target goes through the same stages/.test(hints) && target('dash').dir.some((d) => d.name === '*.xyz') && target('posix').dir.some((d) => d.name === '*.xyz') && !target('bash').members[0].exec); check('the ledger quotes POSIX on interactive shells, so it cannot say it says nothing about them', !(/interactive shell glob/.test(ledger) && /says nothing about what a shell at a prompt/.test(ledger))); check('the pipe ledger does not put the machine\'s boundary at the capacity, since every round starts empty', !/boundary on that machine/.test(ledger) && /began with an empty pipe/.test(ledger) && /Each round makes one pipe/.test(readFileSync(join(ROOT, 'scripts/shell-expansion-pipe-atomicity.py'), 'utf8'))); check('the printed calls are said to be a summary, and execve to be missing its path and environment', /not a full trace/.test(hints) && /the environment as well/.test(hints) && S.run('ls', null, 'dash').members[0].calls.some((c) => /^execve\("ls", \["ls"\]\)$/.test(c.text))); // Round two: the body names the origin of one difference of five (the recordings), // so its summary cannot promise where each one comes from. const shells = SPEC.ledger.find((x) => /differ in five places/.test(x.body)); const origins = (flat(shells.body).match(/found in the recordings|the bash manual|POSIX:/g) || []).length; check('the three-shells ledger entry does not promise an origin for each of its five differences', !!shells && (!/where each difference comes from/.test(shells.summary) || origins >= 5), `${shells && shells.summary}; origins named in the body: ${origins}`); } /* ---- quotations against the archived sources -------------------------------- */ { const QUOTES = [ ['https://man.cat-v.org/unix-7th/1/sh', 'The character $ is used to introduce'], ['https://man.cat-v.org/unix-7th/1/sh', 'After parameter and command substitution, any results of substitution are scanned for internal field separator'], ['https://man.cat-v.org/unix-7th/1/sh', 'are retained. Implicit null arguments (those resulting from parameters that have no values) are removed.'], ['https://man.cat-v.org/unix-7th/1/sh', 'The word is replaced with alphabetically sorted file names that match the pattern.'], ['https://man.cat-v.org/unix-7th/1/sh', 'The character . at the start of a file name or immediately following a /, and the character /, must be matched explicitly.'], ['https://man.cat-v.org/unix-7th/2/dup', 'Dup2 causes fildes2 to refer to the same file as fildes. If fildes2 already referred to an open file, it is closed first.'], ['https://man.cat-v.org/unix-7th/2/pipe', 'Writes with a count of 4096 bytes or less are atomic'], ['https://man.cat-v.org/unix-6th/8/glob', 'Glob is used to expand arguments to the shell containing'], ['https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html', 'Pathname expansion shall not be performed on the word by a non-interactive shell'], ['https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html', 'the order of evaluation is from beginning to end'], ['https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html', 'shall be considered to be assigned by the pipeline before any redirection'], ['https://www.gnu.org/software/bash/manual/html_node/Redirections.html', 'If it expands to more than one word, Bash reports an error.'], ['https://www.gnu.org/software/bash/manual/html_node/Bash-POSIX-Mode.html', 'Redirection operators do not perform filename expansion on the word in a redirection unless the shell is interactive.'], ['https://www.gnu.org/software/bash/manual/html_node/Brace-Expansion.html', 'Brace expansion is performed before any other expansions'], ['https://www.tuhs.org/cgi-bin/utree.pl?file=3BSD/usr/man/man1/csh.1', 'Left to right order is preserved'], ['https://man7.org/linux/man-pages/man7/pipe.7.html', 'On Linux, PIPE_BUF is 4096 bytes.'], ['https://man7.org/linux/man-pages/man7/pipe.7.html', 'the kernel may interleave the data with data written by other processes'], ['https://9p.io/7thEdMan/vol1/man0.bun', 'January, 1979'], ]; const manifest = existsSync(join(ROOT, 'docs/sources/manifest.json')) ? JSON.parse(readFileSync(join(ROOT, 'docs/sources/manifest.json'), 'utf8')).sources : []; const archived = (url) => { const e = manifest.find((m) => m.url === url && m.file); if (e && existsSync(join(ROOT, 'docs/sources', e.file))) return readFileSync(join(ROOT, 'docs/sources', e.file), 'utf8'); const dir = process.env.LA_SOURCES_DIR; const slug = createHash('sha256').update(url).digest('hex').slice(0, 12); if (dir && existsSync(join(dir, `${slug}.txt`))) return readFileSync(join(dir, `${slug}.txt`), 'utf8'); return null; }; const clean = (t) => t.replace(/<[^>]+>/g, ' ').replace(/</g, '<').replace(/>/g, '>') .replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, "'").replace(/­/g, '').replace(/\s+/g, ' '); const pageText = existsSync(PAGE) ? flat(readFileSync(PAGE, 'utf8')) : ''; const missing = QUOTES.filter(([u]) => archived(u) === null).map(([u]) => u); if (missing.length) { check('every quoted source is archived', false, `archive missing: run la-source-audit --archive (${[...new Set(missing)].length} sources, ` + 'or set LA_SOURCES_DIR to a webget.py scratch copy)'); } else { for (const [url, q] of QUOTES) { // A troff bundle holds "<" and ">" in heredocs, so tag-stripping can eat the very // line being quoted; the raw text containing it is evidence enough. check(`the source says: ${q.slice(0, 60)}`, archived(url).includes(q) || clean(archived(url)).includes(q), url); } } for (const [url, q] of QUOTES) { if (q === 'January, 1979') continue; check(`and the page quotes it as printed: ${q.slice(0, 50)}`, pageText.includes(q)); check(`and cites ${url.slice(0, 60)}`, SPEC.sources.some((s) => s.url === url)); } } /* ---- break it on purpose ------------------------------------------------------ */ { // Each mutation must turn a load-time row red, or make the engine disagree // with the recorded shells. Bounded: the sweep for a mutant runs on the // hand-written lines only. const mutants = [ ['glob before split (no split at all)', [' var fields = split(chars);\n var globbed', ' var fields = [chars];\n var globbed']], ['quoted blanks split as well', ['if (!ch.q && ch.x && BLANKS', 'if (ch.x && BLANKS']], ['bash stops globbing its targets', ['bash: { name: "bash", says: "bash", targetSplitGlob: true,', 'bash: { name: "bash", says: "bash", targetSplitGlob: false,']], ['dash stops matching . and ..', ['caretNegates: false, dotDirs: true,', 'caretNegates: false, dotDirs: false,']], ['dash reads ^ as not', ['caretNegates: false, dotDirs: true,', 'caretNegates: true, dotDirs: true,']], ['dash keeps fd 2 open on a failed dup', [' closesBeforeDup: true },', ' closesBeforeDup: false },']], ['the leading dot is not protected', ['if (name.charAt(0) === "." && !(', 'if (false && !(']], ['reverse byte order', ['function byteOrder(a, b) { return a < b ? -1 : a > b ? 1 : 0; }', 'function byteOrder(a, b) { return a < b ? 1 : a > b ? -1 : 0; }']], ['dup2 copies the wrong way', [' table[r.fd] = table[r.from];', ' table[r.from] = table[r.fd];']], ['open does not take the lowest free descriptor', ['for (var fd = 0; ; fd += 1)', 'for (var fd = 3; ; fd += 1)']], ['the pipe never reaches stdout', [' if (mi < n - 1) {', ' if (false) {']], // Three the red team found alive on 2026-10-08, and three for its fixes. ['dqbs: a backslash before an ordinary character in double quotes is dropped', [' } else { cur.parts.push({ t: "lit", c: "\\\\", q: true }); i += 1; }', ' } else { i += 1; }']], ['bang: $! is not a special parameter', ['if (/[0-9@*#?$!-]/.test(n)', 'if (/[0-9@*#?$-]/.test(n)']], ['tab: only a space is a blank', [' var BLANKS = " \\t\\n";', ' var BLANKS = " ";']], ['character classes read as plain members', ['if (!ch.q && ch.c === "[" && j + 1 < n', 'if (false && j + 1 < n']], ['$IFS is empty', [': p.name === "IFS" ? BLANKS : "";', ': "";']], ['n>&n on a closed descriptor fails', ['if (!(r.from in table) && r.from !== r.fd) {', 'if (!(r.from in table)) {']], ]; const hand = F.cases.filter((c) => c.kind === 'hand'); MUTANTS = mutants.length; for (const [label, [from, to]] of mutants) { if (!ENGINE_SRC.includes(from)) { check(`mutant "${label}" is caught`, false, 'the edit did not match the engine'); continue; } let M; try { M = engine(ENGINE_SRC.replace(from, to)); } catch (e) { check(`mutant "${label}" is caught`, false, `did not load: ${e.message}`); continue; } let red = false; let rows = 'verify() threw'; try { const v = M.verify(); red = v.some((n) => !n.ok); rows = `${v.filter((n) => !n.ok).length} rows red`; } catch (e) { red = false; } let differ = 0; for (const c of hand) { for (const shell of F.shells) { let res; try { res = M.run(c.line, F.vars, shell); } catch (e) { differ += 1; continue; } if (res.ok && !same(normalise(res), recorded(c.shells[shell]))) differ += 1; } } let traced = 0; try { traced = traceDiffs(M).length; } catch (e) { traced = 1; } let extra = 0; try { extra = extraFailures(M).length; } catch (e) { extra = 1; } check(`mutant "${label}" is caught`, red || differ > 0 || traced > 0 || extra > 0, `${rows}; ${differ} recorded outcomes differ; ${traced} traces differ; ${extra} red-team lines wrong`); } check('an unmutated copy passes every one of its own checks', engine(ENGINE_SRC).verify().every((n) => n.ok)); } const FLOOR = 150; check(`at least ${FLOOR} checks ran`, p + f >= FLOOR, String(p + f)); console.log(`\nshell-expansion: ${p} passed, ${f} failed. The engine is held against ` + `${F.cases.length} lines run through real dash ${F.versions.dash} and ${F.versions.bash.split(' (')[0]}, ` + `against strace, against a replay of its own calls, and against ${MUTANTS} deliberate faults.`); process.exit(f ? 1 : 0);