175 lines
7.8 KiB
JavaScript
175 lines
7.8 KiB
JavaScript
// tests/hooks/bash-guard-opener-word.test.mjs
|
|
// [PQO] Fix round 4.5b, part 3 (decision 89).
|
|
// Defect: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs found
|
|
// heredoc openers with one regex that needs the whole word quoted, so an
|
|
// opener whose word is only partly quoted (<<"VOYAGE"_ARGS_EOF_7F3C9E), quoted
|
|
// as $'...' or $"...", or split by a backslash-newline was not recognised: the
|
|
// guard exited 0 and the shell ran the line after the closing line. The guard
|
|
// must read the opener's word as the shell does, and treat a word it cannot
|
|
// read as unquoted.
|
|
// The helpers ARGS_DELIM, ARGS_RULE, PARSER_LINE, bashInput,
|
|
// expectArgsBlocked and expectArgsAllowed are copied from
|
|
// tests/hooks/bash-guard.test.mjs:21-23,327-341, not imported, because
|
|
// importing a test file would run its tests. The guard is only spawned, so
|
|
// this file loads at ce98f62 and its red is an assertion.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { runHook } from '../helpers/hook-helper.mjs';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(HERE, '..', '..');
|
|
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
|
|
|
|
function bashInput(command) {
|
|
return { tool_name: 'Bash', tool_input: { command } };
|
|
}
|
|
|
|
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
|
|
const ARGS_RULE = /args-heredoc-closed-early/;
|
|
const PARSER_LINE = 'node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin';
|
|
|
|
async function expectArgsBlocked(command) {
|
|
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
|
|
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early for: ${JSON.stringify(command)}`);
|
|
assert.strictEqual(code, 2, `the guard did not block: ${JSON.stringify(command)}`);
|
|
return stderr;
|
|
}
|
|
|
|
async function expectArgsAllowed(command) {
|
|
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
|
|
assert.doesNotMatch(stderr, ARGS_RULE, `the guard refused a well-formed heredoc: ${JSON.stringify(command)}`);
|
|
assert.strictEqual(code, 0, `the guard refused a well-formed heredoc: ${stderr}`);
|
|
}
|
|
|
|
// The unquoted reason: the guard blocks and says the opener is unquoted.
|
|
const UNQUOTED = /opens VOYAGE_ARGS_EOF_7F3C9E unquoted/;
|
|
|
|
async function expectUnquoted(command) {
|
|
const stderr = await expectArgsBlocked(command);
|
|
assert.match(stderr, UNQUOTED, `the guard did not give the unquoted reason for: ${JSON.stringify(command)}`);
|
|
}
|
|
|
|
const D = ARGS_DELIM;
|
|
const BODY = `\nbrief.md\n${D}`;
|
|
const EARLY = `${BODY}\ntouch PWNED`;
|
|
|
|
// The five backslash-newline openers (the brief's).
|
|
const CONTINUATION_OPENERS = [
|
|
`<<\\\n${D}`,
|
|
`<<VOYAGE_ARGS\\\n_EOF_7F3C9E`,
|
|
`<\\\n<${D}`,
|
|
`<< \\\n${D}`,
|
|
`<<-\\\n${D}`,
|
|
];
|
|
const X5F_OPENER = `<<$'VOYAGE\\x5fARGS_EOF_7F3C9E'`;
|
|
const DQ_CONTINUATION_OPENERS = [
|
|
`<<"VOYAGE_ARGS\\\n_EOF_7F3C9E"`,
|
|
`<<VOYAGE"_ARGS\\\n_EOF_7F3C9E"`,
|
|
];
|
|
const OCTAL_OPENER = `<<$'\\126\\117YAGE_ARGS_EOF_7F3C9E'`;
|
|
const DASH_AFTER_CONTINUATION_OPENER = `<<\\\n-"VOYAGE"_ARGS_EOF_7F3C9E`;
|
|
const UNTERMINATED_OPENER = `<<V"OYAGE_ARGS_EOF_7F3C9E`;
|
|
const LOCALE_BACKSLASH_OPENER = `<<$"VOYAGE\\_ARGS_EOF_7F3C9E"`;
|
|
|
|
// The brief's 11 spellings.
|
|
const BRIEF_SPELLINGS = [
|
|
`<<"VOYAGE"_ARGS_EOF_7F3C9E`,
|
|
`<<V'OYAGE_ARGS_EOF_7F3C9E'`,
|
|
`<<VOYAGE\\_ARGS_EOF_7F3C9E`,
|
|
`<<$'VOYAGE_ARGS_EOF_7F3C9E'`,
|
|
`<<- "VOYAGE"_ARGS_EOF_7F3C9E`,
|
|
X5F_OPENER,
|
|
...CONTINUATION_OPENERS,
|
|
];
|
|
|
|
// 8 from planning, each measured to close at the plain line or shell-dependent.
|
|
const PLANNING_SPELLINGS = [
|
|
...DQ_CONTINUATION_OPENERS,
|
|
OCTAL_OPENER,
|
|
`<<$"VOYAGE_ARGS_EOF_7F3C9E"`,
|
|
DASH_AFTER_CONTINUATION_OPENER,
|
|
`<<-\t"VOYAGE"_ARGS_EOF_7F3C9E`,
|
|
UNTERMINATED_OPENER,
|
|
LOCALE_BACKSLASH_OPENER,
|
|
];
|
|
|
|
test('[PQO] the guard reads a heredoc opener\'s word as the shell does and checks every spelling of the delimiter', async () => {
|
|
// Red: every spelling, followed by a body, the closing line and a command.
|
|
assert.equal(BRIEF_SPELLINGS.length + PLANNING_SPELLINGS.length, 19);
|
|
for (const opener of [...BRIEF_SPELLINGS, ...PLANNING_SPELLINGS]) {
|
|
await expectArgsBlocked(`${PARSER_LINE} ${opener}${EARLY}`);
|
|
}
|
|
// The unterminated quote the regex does not match also gets the unquoted reason.
|
|
await expectUnquoted(`${PARSER_LINE} ${UNTERMINATED_OPENER}${EARLY}`);
|
|
|
|
// Red: word ends. Unquoted whitespace or one of ; & | ( ) < > ends the word.
|
|
for (const end of [';echo x', '>/dev/null', '|cat', '&', '</dev/null', ')']) {
|
|
await expectArgsBlocked(`${PARSER_LINE} <<"VOYAGE"_ARGS_EOF_7F3C9E${end}${EARLY}`);
|
|
}
|
|
|
|
// Red: a fake accepted opener inside a string hides no real opener after it.
|
|
await expectUnquoted(`echo "<<'${D}' x"\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)\n${D}`);
|
|
await expectUnquoted(`echo "<<'${D}' x"\n${PARSER_LINE} <<\\\n-VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\n${D}`);
|
|
|
|
// Red: a quoted word spanning lines hides nothing, in both quote kinds.
|
|
await expectUnquoted(`echo "<<'"\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED) don't\n${D}`);
|
|
await expectUnquoted(`echo '<<"'\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED) say "hi\n${D}`);
|
|
|
|
// Red: a typed argument with another unreadable word after << (Known issue 3b).
|
|
await expectUnquoted(`${PARSER_LINE} <<'${D}'\nx <<$'\\t' y\n${D}`);
|
|
|
|
// Red: lines stay raw. A body line ending in a backslash does not join the
|
|
// early closing line to it.
|
|
await expectArgsBlocked(`${PARSER_LINE} <<"VOYAGE"_ARGS_EOF_7F3C9E\nfoo\\\n${D}\ntouch PWNED\n${D}`);
|
|
|
|
// Red: unquoted reason, with EARLY and with BODY (closing line last).
|
|
const unquotedOpeners = [
|
|
...CONTINUATION_OPENERS,
|
|
X5F_OPENER,
|
|
...DQ_CONTINUATION_OPENERS,
|
|
OCTAL_OPENER,
|
|
DASH_AFTER_CONTINUATION_OPENER,
|
|
LOCALE_BACKSLASH_OPENER,
|
|
];
|
|
for (const opener of unquotedOpeners) {
|
|
await expectUnquoted(`${PARSER_LINE} ${opener}${EARLY}`);
|
|
await expectUnquoted(`${PARSER_LINE} ${opener}${BODY}`);
|
|
}
|
|
|
|
// Control: an unterminated "-quoted delimiter with a body is blocked as unquoted.
|
|
await expectUnquoted(`${PARSER_LINE} <<"${D}\nbrief.md`);
|
|
// Control: well-formed spellings with the closing line last pass.
|
|
for (const opener of [
|
|
`<<"VOYAGE"_ARGS_EOF_7F3C9E`,
|
|
`<<V'OYAGE_ARGS_EOF_7F3C9E'`,
|
|
`<<VOYAGE\\_ARGS_EOF_7F3C9E`,
|
|
`<<$'VOYAGE_ARGS_EOF_7F3C9E'`,
|
|
`<<- "VOYAGE"_ARGS_EOF_7F3C9E`,
|
|
`<<$"VOYAGE_ARGS_EOF_7F3C9E"`,
|
|
`<<"VOYAGE"_ARGS_EOF_7F3C9E;echo x`,
|
|
]) {
|
|
await expectArgsAllowed(`${PARSER_LINE} ${opener}${BODY}`);
|
|
}
|
|
// Control: the shipped form, and the backslash-quoted opener.
|
|
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nbrief.md Session 2 of 3\n${D}`);
|
|
await expectArgsAllowed(`${PARSER_LINE} <<\\${D}${BODY}`);
|
|
// Control: typed arguments inside the shipped heredoc pass (the operator's decision).
|
|
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nfix "<<" handling\n${D}`);
|
|
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nFix <<don't>> bug\n${D}`);
|
|
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nx <<"VOYAGE"_ARGS_EOF_7F3C9E y\n${D}`);
|
|
// Control: a continuation before the opener passes.
|
|
await expectArgsAllowed(`node /plugin/lib/parsers/arg-parser.mjs --command trekendsession \\\n --stdin <<'${D}'${BODY}`);
|
|
// Control: a one-line mention passes; with a line after it, it is blocked.
|
|
await expectArgsAllowed(`grep -n "<<'${D}'" f`);
|
|
await expectArgsBlocked(`grep -n "<<'${D}'" f\necho x`);
|
|
// Control: a delimiter line before a correct heredoc is blocked.
|
|
await expectArgsBlocked(`echo hi\n${D}\n${PARSER_LINE} <<'${D}'${BODY}`);
|
|
// Control: openers cut off at the end of the text never make the guard exit 1.
|
|
await expectArgsAllowed(`echo ${D}\nx <<\\`);
|
|
await expectArgsAllowed(`echo ${D}\nx <<"`);
|
|
await expectArgsAllowed(`echo ${D}\nx <<$'`);
|
|
await expectArgsAllowed(`echo ${D}\nx <\\`);
|
|
});
|