diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f680a832415..a6d5fbffedc 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -614,6 +614,22 @@ jobs: - name: Install ripgrep run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep) + - name: Verify shell placeholder compilation in Bash + if: matrix.shard == 1 + working-directory: apps/sim + env: + SHELL_PLACEHOLDERS_REPORT_PATH: ${{ runner.temp }}/shell-placeholders.json + run: bun scripts/test-shell-placeholders-e2e.ts + + - name: Upload shell placeholder execution report + if: failure() && matrix.shard == 1 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: shell-placeholders + path: ${{ runner.temp }}/shell-placeholders.json + if-no-files-found: warn + retention-days: 7 + - name: Run tests env: NODE_OPTIONS: '--no-warnings --max-old-space-size=8192' diff --git a/apps/sim/lib/execution/code-placeholders/shared.ts b/apps/sim/lib/execution/code-placeholders/shared.ts index 177e7d21d5b..523a44e0e62 100644 --- a/apps/sim/lib/execution/code-placeholders/shared.ts +++ b/apps/sim/lib/execution/code-placeholders/shared.ts @@ -327,12 +327,12 @@ export function createOffsetRangeLookup( * The placeholders lying wholly inside `[start, end]`. Occurrences are ordered and never * overlap, so the matches are one contiguous run found by bisection. */ -export function occurrencesWithin( - occurrences: readonly CodePlaceholderOccurrence[], +export function occurrencesWithin>( + occurrences: readonly T[], start: number, end: number -): CodePlaceholderOccurrence[] { - const matches: CodePlaceholderOccurrence[] = [] +): T[] { + const matches: T[] = [] for ( let index = partitionPoint(occurrences, (occurrence) => occurrence.start < start); index < occurrences.length && occurrences[index].end <= end; diff --git a/apps/sim/lib/execution/code-placeholders/shell.ts b/apps/sim/lib/execution/code-placeholders/shell.ts index efa1660a309..9bcf5cec061 100644 --- a/apps/sim/lib/execution/code-placeholders/shell.ts +++ b/apps/sim/lib/execution/code-placeholders/shell.ts @@ -28,11 +28,22 @@ interface HeredocDeclaration { type ShellQuote = 'none' | 'single' | 'double' | 'ansi' interface ShellScanFrame { - kind: 'root' | 'command' | 'arithmetic' | 'backtick' + kind: 'root' | 'command' | 'arithmetic' | 'backtick' | 'parameter' quote: ShellQuote parenthesisDepth: number bracketDepth?: number literalRoot: boolean + inDoubleQuotes?: boolean + commandStarted?: boolean + commandPrefix?: 'time' | 'coproc' + wordEnd?: number + conditional?: { + start: number + arithmetic: boolean + words: number + wordOpen: boolean + unary: boolean + } } interface ShellOccurrenceContext { @@ -42,6 +53,51 @@ interface ShellOccurrenceContext { unsupported?: 'escaped sequence' } +interface ShellSpan { + start: number + end: number +} + +const ARITHMETIC_COMPARISON = /^-(?:eq|ne|lt|le|gt|ge)$/ +const SHELL_WORD = /(?:\\[\s\S]|[^\s;&|()<>\\])+/y +const COMMAND_INTRODUCERS = new Set([ + 'if', + 'then', + 'elif', + 'else', + 'while', + 'until', + 'do', + '!', + 'time', + 'coproc', +]) + +function shellWordStarts(code: string, index: number): boolean { + return index === 0 || /[\s;&|()<>]/.test(code[index - 1]) +} + +function followsRedirect(code: string, index: number): boolean { + let previous = index - 1 + while (code[previous] === ' ' || code[previous] === '\t') previous -= 1 + return code[previous] === '<' || code[previous] === '>' +} + +function effectiveQuote(frame: ShellScanFrame): ShellQuote { + return frame.kind === 'parameter' && frame.quote === 'none' && frame.inDoubleQuotes + ? 'double' + : frame.quote +} + +function readShellWord(code: string, index: number): { word: string; end: number } { + SHELL_WORD.lastIndex = index + const raw = SHELL_WORD.exec(code)?.[0] ?? '' + return { + word: raw.replace(/\\([\s\S])/g, (escaped, character) => (character === '\n' ? '' : escaped)), + end: index + raw.length, + } +} + function lineEndAfterNewline(code: string, start: number): number { const newline = code.indexOf('\n', start) return newline === -1 ? code.length : newline + 1 @@ -474,25 +530,34 @@ function heredocBodyRanges(heredocs: HeredocDeclaration[]): Array<[number, numbe * Jumps over `skippedRanges` (sorted heredoc bodies, which bash reads as data) so body prose * cannot shift quote context; bodies that need contexts are scanned on their own with `literalRoot`. */ -function collectShellOccurrenceContexts( +function collectShellOccurrenceContexts( code: string, - occurrences: CodePlaceholderOccurrence[], + occurrences: readonly T[], start: number, end: number, literalRoot: boolean, skippedRanges: Array<[number, number]> = [] -): Map { +): Map { const occurrenceByStart = new Map( occurrencesWithin(occurrences, start, end).map( (occurrence) => [occurrence.start, occurrence] as const ) ) - const contexts = new Map() + const contexts = new Map() const frames: ShellScanFrame[] = [ { kind: 'root', quote: 'none', parenthesisDepth: 0, literalRoot }, ] let skippedRangeIndex = 0 let arithmeticDepth = 0 + const conditionalArithmeticRanges: Array<[number, number]> = [] + const endConditionalOperand = (frame: ShellScanFrame, end: number) => { + if (frame.conditional?.arithmetic) { + conditionalArithmeticRanges.push([frame.conditional.start, end]) + } + if (frame.conditional) { + frame.conditional = { start: end, arithmetic: false, words: 0, wordOpen: false, unary: false } + } + } for (let index = start; index < end; ) { const frame = frames.at(-1) @@ -510,14 +575,111 @@ function collectShellOccurrenceContexts( continue } + const character = code[index] + if ( + frame.quote === 'none' && + !frame.literalRoot && + frame.kind !== 'arithmetic' && + frame.kind !== 'parameter' + ) { + if (frame.conditional) { + if (character === '\\' && code[index + 1] === '\n') { + index += 2 + continue + } + if ((character === '&' || character === '|') && code[index + 1] === character) { + endConditionalOperand(frame, index) + } + if (/\s|[()]/.test(character)) { + frame.conditional.wordOpen = false + } else if (!frame.conditional.wordOpen && character !== '&' && character !== '|') { + const { word } = readShellWord(code, index) + if (word === ']]') { + endConditionalOperand(frame, index) + frame.conditional = undefined + } else { + const conditional = frame.conditional + conditional.wordOpen = true + if (word !== '!' || conditional.words > 0) { + if ( + conditional.words === 1 && + !conditional.unary && + word && + ARITHMETIC_COMPARISON.test(word) + ) { + conditional.arithmetic = true + } + if (conditional.words === 0) conditional.unary = /^-[a-zA-Z]$/.test(word ?? '') + conditional.words += 1 + } + } + } + } else if ( + /[\n;()]/.test(character) || + ((character === '&' || character === '|') && + !/[<>]/.test(code[index - 1] ?? '') && + !(character === '&' && code[index + 1] === '>')) || + (character === '{' && + shellWordStarts(code, index) && + readShellWord(code, index).word === '{') + ) { + frame.commandStarted = false + frame.commandPrefix = undefined + } else if ( + !frame.commandStarted && + index >= (frame.wordEnd ?? start) && + shellWordStarts(code, index) + ) { + const { word, end: wordEnd } = readShellWord(code, index) + frame.wordEnd = wordEnd + if ( + word && + !followsRedirect(code, index) && + !/^\d+(?=[<>])/.test(code.slice(index)) && + !/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) + ) { + const prefixArgument = + (frame.commandPrefix === 'time' && word === '-p') || + (frame.commandPrefix === 'coproc' && word !== '[[' && !COMMAND_INTRODUCERS.has(word)) + frame.commandPrefix = word === 'time' || word === 'coproc' ? word : undefined + if (!COMMAND_INTRODUCERS.has(word) && !prefixArgument) { + frame.commandStarted = true + if (word === '[[') { + frame.conditional = { + start: index, + arithmetic: false, + words: 0, + wordOpen: true, + unary: false, + } + } + } + } + } + } const occurrence = occurrenceByStart.get(index) if (occurrence) { - contexts.set(occurrence, { quote: frame.quote, arithmetic: arithmeticDepth > 0 }) + contexts.set(occurrence, { quote: effectiveQuote(frame), arithmetic: arithmeticDepth > 0 }) index = occurrence.end continue } - - const character = code[index] + if ( + character === '$' && + code[index + 1] === '{' && + !occurrenceByStart.has(index + 1) && + frame.kind !== 'arithmetic' && + (frame.quote === 'none' || frame.quote === 'double') + ) { + frames.push({ + kind: 'parameter', + quote: 'none', + parenthesisDepth: 0, + literalRoot: false, + inDoubleQuotes: effectiveQuote(frame) === 'double' || frame.literalRoot, + }) + index += 2 + continue + } if (frame.quote === 'single') { if (character === "'") frame.quote = 'none' index += 1 @@ -542,7 +704,10 @@ function collectShellOccurrenceContexts( character === '$' && ((code[index + 1] === '(' && code[index + 2] === '(') || code[index + 1] === '[') const arithmeticCommand = - frame.quote === 'none' && !frame.literalRoot && shellArithmeticCommandStarts(code, index) + frame.quote === 'none' && + !frame.literalRoot && + frame.kind !== 'parameter' && + shellArithmeticCommandStarts(code, index) if (arithmeticExpansion || arithmeticCommand) { const brackets = arithmeticExpansion && code[index + 1] === '[' frames.push({ @@ -595,7 +760,13 @@ function collectShellOccurrenceContexts( index += 1 continue } - if (frame.kind !== 'arithmetic' && !frame.literalRoot && shellCommentStarts(code, index)) { + if ( + frame.kind !== 'arithmetic' && + frame.kind !== 'parameter' && + !frame.literalRoot && + shellCommentStarts(code, index) + ) { + if (!frame.conditional) frame.commandStarted = false const newline = code.indexOf('\n', index) index = newline === -1 || newline >= end ? end : newline + 1 continue @@ -610,12 +781,18 @@ function collectShellOccurrenceContexts( } continue } - if (!frame.literalRoot && character === '$' && code[index + 1] === "'") { + const singleQuotesLiteral = frame.kind === 'parameter' && frame.inDoubleQuotes + if ( + !frame.literalRoot && + !singleQuotesLiteral && + character === '$' && + code[index + 1] === "'" + ) { frame.quote = 'ansi' index += 2 continue } - if (!frame.literalRoot && character === "'") { + if (!frame.literalRoot && !singleQuotesLiteral && character === "'") { frame.quote = 'single' index += 1 continue @@ -625,7 +802,14 @@ function collectShellOccurrenceContexts( index += 1 continue } - if (character === '$' && code[index + 1] === '(') { + if ( + code[index + 1] === '(' && + (character === '$' || + ((character === '<' || character === '>') && + !frame.literalRoot && + frame.kind !== 'arithmetic' && + effectiveQuote(frame) === 'none')) + ) { frames.push({ kind: 'command', quote: 'none', @@ -645,6 +829,11 @@ function collectShellOccurrenceContexts( index += 1 continue } + if (frame.kind === 'parameter' && character === '}') { + frames.pop() + index += 1 + continue + } if (frame.kind === 'arithmetic' && frame.bracketDepth !== undefined) { if (character === '[') frame.bracketDepth += 1 if (character === ']') { @@ -674,6 +863,11 @@ function collectShellOccurrenceContexts( index += 1 } + for (const frame of frames) endConditionalOperand(frame, end) + const inConditionalArithmetic = createOffsetRangeLookup(conditionalArithmeticRanges) + for (const [occurrence, context] of contexts) { + if (inConditionalArithmetic(occurrence.start)) context.arithmetic = true + } return contexts } @@ -764,6 +958,11 @@ function recordShellDirectEnvironmentReads( } } +/** + * Binds values without inserting them into shell source, rejecting placeholders in explicit + * arithmetic delimiters and arithmetic comparisons. This lexical guard does not follow later + * evaluation through variable attributes, indirect command names, or commands such as `eval`. + */ export async function compileShellPlaceholders( input: InternalCompileCodePlaceholdersInput ): Promise { @@ -788,36 +987,87 @@ export async function compileShellPlaceholders( validateShellValue(occurrence, context.resolve(occurrence)) const resolveShellValue = (occurrence: CodePlaceholderOccurrence) => validateShellValue(occurrence, context.resolveValue(occurrence)) + /** A placeholder with no value stays as written; analysis still discovers one with a value. */ + const rejectUnsupported = (occurrence: CodePlaceholderOccurrence, position: string) => { + if (!context.hasValue(occurrence.name)) return + if (input.analysisOnly) { + context.resolveValue(occurrence) + return + } + throw new CodePlaceholderCompileError( + `Variable placeholder "${occurrence.name}" is not supported ${position}`, + input.code, + occurrence.start + ) + } + /** Heredoc bodies are data, so only code outside them can name an assignment target. */ + const resolveInContext = ( + occurrence: CodePlaceholderOccurrence, + occurrenceContext: ShellOccurrenceContext | undefined, + inCode: boolean + ): SourceEdit | undefined => { + if (!occurrenceContext) return undefined + if (occurrenceContext.unsupported) { + rejectUnsupported(occurrence, 'in an escaped shell sequence') + return undefined + } + const unsupportedPosition = + getUnsupportedShellPosition(input.code, occurrence, occurrenceContext) ?? + (inCode && occurrenceContext.quote === 'none' && isShellAssignmentName(input.code, occurrence) + ? 'as a shell assignment name' + : undefined) + if (unsupportedPosition) { + rejectUnsupported(occurrence, unsupportedPosition) + return undefined + } + const resolved = resolveShellOccurrence(occurrence) + return { + start: occurrence.start, + end: occurrence.end, + text: resolved ? shellExpansion(resolved.bindingName, occurrenceContext.quote) : '', + } + } const heredocs = collectHeredocs(input.code) const shellOccurrences = context.occurrences.filter(isLegacyShellPlaceholder) + const isExcluded = createOffsetRangeLookup( + heredocs.flatMap( + (heredoc): Array<[number, number]> => [ + [heredoc.operatorStart, heredoc.operatorEnd], + [heredoc.bodyStart, heredoc.removalEnd], + ] + ) + ) + const rootOccurrences = shellOccurrences.filter((occurrence) => !isExcluded(occurrence.start)) + /** A heredoc operator's context is where its body lands, so it is scanned like a placeholder. */ + const heredocOperators = heredocs.map( + (heredoc): ShellSpan => ({ start: heredoc.operatorStart, end: heredoc.operatorEnd }) + ) + const rootContexts = collectShellOccurrenceContexts( + input.code, + [...rootOccurrences, ...heredocOperators].sort((left, right) => left.start - right.start), + 0, + input.code.length, + false, + heredocBodyRanges(heredocs) + ) const edits: SourceEdit[] = [] - const excludedRanges: Array<[number, number]> = [] - - for (const heredoc of heredocs) { - excludedRanges.push([heredoc.operatorStart, heredoc.operatorEnd]) - excludedRanges.push([heredoc.bodyStart, heredoc.removalEnd]) + for (const [heredocIndex, heredoc] of heredocs.entries()) { const delimiterOccurrences = occurrencesWithin( shellOccurrences, heredoc.operatorStart, heredoc.operatorEnd ) for (const occurrence of delimiterOccurrences) { - if (context.hasValue(occurrence.name)) { - if (input.analysisOnly) { - context.resolveValue(occurrence) - continue - } - throw new CodePlaceholderCompileError( - `Variable placeholder "${occurrence.name}" is not supported in a shell heredoc delimiter`, - input.code, - occurrence.start - ) - } + rejectUnsupported(occurrence, 'in a shell heredoc delimiter') } const bodyOccurrences = occurrencesWithin(shellOccurrences, heredoc.bodyStart, heredoc.bodyEnd) + if (rootContexts.get(heredocOperators[heredocIndex])?.arithmetic) { + for (const occurrence of bodyOccurrences) rejectUnsupported(occurrence, 'in shell arithmetic') + continue + } if (heredoc.quoted) { const bodyEdits: SourceEdit[] = [] let hasResolvedPlaceholder = false @@ -868,116 +1118,14 @@ export async function compileShellPlaceholders( true ) for (const occurrence of bodyOccurrences) { - const occurrenceContext = bodyContexts.get(occurrence) - if (!occurrenceContext) continue - if (occurrenceContext.unsupported) { - if (context.hasValue(occurrence.name)) { - if (input.analysisOnly) { - context.resolveValue(occurrence) - continue - } - throw new CodePlaceholderCompileError( - `Variable placeholder "${occurrence.name}" is not supported in an escaped shell sequence`, - input.code, - occurrence.start - ) - } - continue - } - const unsupportedPosition = getUnsupportedShellPosition( - input.code, - occurrence, - occurrenceContext - ) - if (unsupportedPosition) { - if (context.hasValue(occurrence.name)) { - if (input.analysisOnly) { - context.resolveValue(occurrence) - continue - } - throw new CodePlaceholderCompileError( - `Variable placeholder "${occurrence.name}" is not supported ${unsupportedPosition}`, - input.code, - occurrence.start - ) - } - continue - } - const resolved = resolveShellOccurrence(occurrence) - edits.push({ - start: occurrence.start, - end: occurrence.end, - text: resolved ? shellExpansion(resolved.bindingName, occurrenceContext.quote) : '', - }) + const edit = resolveInContext(occurrence, bodyContexts.get(occurrence), false) + if (edit) edits.push(edit) } } - const isExcluded = createOffsetRangeLookup(excludedRanges) - const rootOccurrences = shellOccurrences.filter((occurrence) => !isExcluded(occurrence.start)) - const rootContexts = collectShellOccurrenceContexts( - input.code, - rootOccurrences, - 0, - input.code.length, - false, - heredocBodyRanges(heredocs) - ) for (const occurrence of rootOccurrences) { - const occurrenceContext = rootContexts.get(occurrence) - if (!occurrenceContext) continue - if (occurrenceContext.unsupported) { - if (context.hasValue(occurrence.name)) { - if (input.analysisOnly) { - context.resolveValue(occurrence) - continue - } - throw new CodePlaceholderCompileError( - `Variable placeholder "${occurrence.name}" is not supported in an escaped shell sequence`, - input.code, - occurrence.start - ) - } - continue - } - const unsupportedPosition = getUnsupportedShellPosition( - input.code, - occurrence, - occurrenceContext - ) - if (unsupportedPosition) { - if (context.hasValue(occurrence.name)) { - if (input.analysisOnly) { - context.resolveValue(occurrence) - continue - } - throw new CodePlaceholderCompileError( - `Variable placeholder "${occurrence.name}" is not supported ${unsupportedPosition}`, - input.code, - occurrence.start - ) - } - continue - } - if (occurrenceContext.quote === 'none' && isShellAssignmentName(input.code, occurrence)) { - if (context.hasValue(occurrence.name)) { - if (input.analysisOnly) { - context.resolveValue(occurrence) - continue - } - throw new CodePlaceholderCompileError( - `Variable placeholder "${occurrence.name}" is not supported as a shell assignment name`, - input.code, - occurrence.start - ) - } - continue - } - const resolved = resolveShellOccurrence(occurrence) - edits.push({ - start: occurrence.start, - end: occurrence.end, - text: resolved ? shellExpansion(resolved.bindingName, occurrenceContext.quote) : '', - }) + const edit = resolveInContext(occurrence, rootContexts.get(occurrence), true) + if (edit) edits.push(edit) } return context.finish(applySourceEdits(input.code, edits)) diff --git a/apps/sim/scripts/test-shell-placeholders-e2e.ts b/apps/sim/scripts/test-shell-placeholders-e2e.ts new file mode 100644 index 00000000000..4902879f74c --- /dev/null +++ b/apps/sim/scripts/test-shell-placeholders-e2e.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { + CodePlaceholderCompileError, + type CompiledCodePlaceholders, + compileCodePlaceholders, +} from '@/lib/execution/code-placeholders' +import { CodeLanguage } from '@/lib/execution/languages' + +/** + * Exercises the compiler's environment bindings and private inputs in real Bash. + * Run from apps/sim with SHELL_PLACEHOLDERS_REPORT_PATH and optionally SHELL_PLACEHOLDERS_BASH. + * This covers compilation through process execution, not the hosted sandbox transport. + */ +const logger = createLogger('ShellPlaceholdersE2E') +const reportPath = process.env.SHELL_PLACEHOLDERS_REPORT_PATH +assert(reportPath, 'Set SHELL_PLACEHOLDERS_REPORT_PATH') +const bash = process.env.SHELL_PLACEHOLDERS_BASH ?? '/bin/bash' +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] +const directory = mkdtempSync(join(tmpdir(), 'sim-shell-placeholders-')) +const sentinel = join(directory, 'sentinel') +const payload = `values[$(touch '${sentinel}')]` + +function execute(compiled: CompiledCodePlaceholders): string { + const env: NodeJS.ProcessEnv = { NODE_ENV: 'test', PATH: process.env.PATH ?? '/usr/bin:/bin' } + for (const binding of compiled.bindings) env[binding.name] = binding.value + for (const [index, input] of compiled.privateInputs.entries()) { + const path = join(directory, `input-${index}`) + writeFileSync(path, input.content, { mode: 0o600 }) + env[input.environmentVariable] = path + } + const result = spawnSync(bash, ['--noprofile', '--norc', '-c', compiled.code], { + env, + cwd: directory, + encoding: 'utf8', + timeout: 5_000, + maxBuffer: 1024 * 1024, + }) + if (result.error) throw result.error + assert.equal(result.status, 0, result.stderr) + return result.stdout +} + +async function check(name: string, run: () => Promise): Promise { + const start = performance.now() + rmSync(sentinel, { force: true }) + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + } +} + +async function compile(code: string, value: string): Promise { + return compileCodePlaceholders({ code, language: CodeLanguage.Shell, params: { KEY: value } }) +} + +try { + const arithmetic = [ + 'cat <([[ "{{KEY}}" -eq 0 ]])', + '[[ "{{KEY}}" \\\n -eq 0 ]]', + '[[ \\\n "{{KEY}}" -eq 0 ]]', + 'time -p [[ "{{KEY}}" -eq 0 ]]', + 'coproc [[ "{{KEY}}" -eq 0 ]]; wait', + 'coproc worker [[ "{{KEY}}" -eq 0 ]]; wait', + 'coproc "worker" [[ "{{KEY}}" -eq 0 ]]; wait', + 'coproc "$(printf worker)" [[ "{{KEY}}" -eq 0 ]]; wait', + 'if \\\n [[ "{{KEY}}" -eq 0 ]]; then :; fi', + '[\\\n[ "{{KEY}}" -eq 0 ]]', + '[[ "{{KEY}}" -e\\\nq 0 ]]', + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion + '[[ ${missing:- {{KEY}}} -eq 0 ]]', + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion + '[[ ${missing:-"${other:- {{KEY}}}"} -eq 0 ]]', + + '[[ "{{KEY}}" -eq 0 ]]', + '[[ 0 -lt "{{KEY}}" ]]', + 'echo ignored >/dev/null # end command\n[[ "{{KEY}}" -eq 0 ]]', + 'case x in x) [[ "{{KEY}}" -eq 0 ]];; esac', + '[[ $(printf %s "{{KEY}}")+0 -eq 0 ]]', + '[[ $(printf "%s" "{{KEY}}") -ge 0 ]]', + '[[ $(printf "%s" "{{KEY}}"; :) -ne 1 ]]', + '[[ $(cat < { + const binding = '__probe_value' + execute({ + code: code.replaceAll('{{KEY}}', `\${${binding}}`), + bindings: [{ name: binding, value: payload }], + privateInputs: [], + runtimeBindings: [], + resolvedSecretNames: [], + internalIdentifiers: [], + }) + assert(existsSync(sentinel), 'The unprotected expression must execute the sentinel') + }) + } + for (const code of arithmetic) { + await check(`rejects arithmetic: ${code}`, async () => { + await assert.rejects( + () => compile(code, payload), + (error: unknown) => + error instanceof CodePlaceholderCompileError && + error.message.includes('in shell arithmetic') + ) + assert(!existsSync(sentinel)) + }) + } + + for (const value of ['["$5"]', '[WIP', "[don't]", payload, 'a[', '$(printf injected)']) { + for (const code of [ + 'printf "%s\\n" "{{KEY}}"', + "cat <<'EOF'\n{{KEY}}\nEOF", + 'cat < { + assert.equal(execute(await compile(code, value)), `${value}\n`) + assert(!existsSync(sentinel), 'Literal data must not execute') + }) + } + } + + for (const code of [ + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion + 'printf "%s\\n" "${missing:-\'{{KEY}}\'}"', + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion + "cat < { + assert.equal(execute(await compile(code, payload)), `'${payload}'\n`) + assert(!existsSync(sentinel)) + }) + } + + const safePrograms = [ + '[[ "{{KEY}}" < \\\n -eq ]] || printf "%s\\n" ok', + 'time -p printf "%s\\n" "{{KEY}}" >/dev/null; printf "%s\\n" ok', + 'if (( BASH_VERSINFO[0] >= 4 )); then coproc printf "%s\\n" "{{KEY}}" [[ -eq 0 ]]; wait; fi; printf "%s\\n" ok', + // biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion + 'printf %s ${missing:- } [[ "{{KEY}}" -eq 0 ]] >/dev/null; printf "%s\\n" ok', + 'printf %s &>/dev/null [[ "{{KEY}}" -eq 0 ]]; printf "%s\\n" ok', + '[[ "{{KEY}}" == -eq ]] || printf "%s\\n" ok', + '[[ "{{KEY}}" < -eq ]] || printf "%s\\n" ok', + '[[ "{{KEY}}" == values* && 1 -eq 1 ]] && printf "%s\\n" ok', + '[[ 1 -eq 1 && "{{KEY}}" == values* ]] && printf "%s\\n" ok', + '[[ ( "{{KEY}}" == values* ) && 1 -eq 1 ]] && printf "%s\\n" ok', + '[ "{{KEY}}" -eq 0 ] 2>/dev/null || printf "%s\\n" ok', + 'f() { declare -i n; }; n="{{KEY}}"; [[ "$n" == values* ]] && printf "%s\\n" ok', + 'if [[ 1 -eq 1 ]]; then printf "%s\\n" "{{KEY}}" >/dev/null; fi; printf "%s\\n" ok', + 'printf "%s" [[ "{{KEY}}" -eq 0 ]] >/dev/null; printf "%s\\n" ok', + ] + for (const code of safePrograms) { + await check(`preserves safe command: ${code}`, async () => { + assert.equal(execute(await compile(code, payload)), 'ok\n') + assert(!existsSync(sentinel)) + }) + } + await check('preserves a placeholder command name', async () => { + const compiled = await compileCodePlaceholders({ + code: '{{COMMAND}} [[ "{{KEY}}" -eq 0 ]]', + language: CodeLanguage.Shell, + params: { COMMAND: 'printf', KEY: payload }, + }) + assert.equal(execute(compiled), '[[') + assert(!existsSync(sentinel)) + }) + for (const substitution of ['<(printf ignored)', '>(cat)', '<(cat <(printf ignored))']) { + await check(`preserves arguments after ${substitution}`, async () => { + const code = `printf "%s\\n" ${substitution} [[ "{{KEY}}" -eq 0 ]] | tail -n +2` + assert.equal(execute(await compile(code, payload)), `[[\n${payload}\n-eq\n0\n]]\n`) + assert(!existsSync(sentinel)) + }) + } + await check('preserves literal values inside process substitutions', async () => { + assert.equal(execute(await compile('cat <(printf "%s\\n" "{{KEY}}")', payload)), `${payload}\n`) + assert(!existsSync(sentinel)) + }) +} finally { + rmSync(directory, { recursive: true, force: true }) + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ bash, checks }, null, 2)) +} +const failed = checks.filter((result) => result.status === 'failed') +logger.info('Shell placeholder execution complete', { + passed: checks.length - failed.length, + failed: failed.length, + reportPath, +}) +if (failed.length) process.exitCode = 1