From 4c9ea92e99bfd12615dc3921b0f87e9873c333d4 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 5 Oct 2026 11:01:12 -0700 Subject: [PATCH] fix(code-placeholders): reject shell arithmetic placeholders --- .../code-placeholders/compiler.test.ts | 58 +++++++++++++++++++ .../lib/execution/code-placeholders/shell.ts | 56 +++++++++++++++--- 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/execution/code-placeholders/compiler.test.ts b/apps/sim/lib/execution/code-placeholders/compiler.test.ts index d7ba6b94d1c..96cc224341e 100644 --- a/apps/sim/lib/execution/code-placeholders/compiler.test.ts +++ b/apps/sim/lib/execution/code-placeholders/compiler.test.ts @@ -1039,6 +1039,64 @@ describe('code placeholder compiler', () => { } }) + it.each([ + 'total=$(( {{KEY}} * 2 ))', + 'printf "%s" "$(( {{KEY}} * 2 ))"', + 'total=$(( "{{KEY}}" * 2 ))', + 'total=$[ {{KEY}} * 2 ]', + 'printf "%s" "$[ {{KEY}} * 2 ]"', + 'total=$[ values[0] + {{KEY}} ]', + '(( total = {{KEY}} * 2 ))', + 'for (( i = {{KEY}}; i < 2; i++ )); do :; done', + 'total=$(( $(printf "%s" "{{KEY}}") * 2 ))', + 'total=$(( `printf "%s" "{{KEY}}"` * 2 ))', + 'total=$(( $(( 1 + 1 )) + {{KEY}} ))', + 'cat < { + await expect( + compileCodePlaceholders({ + code, + language: CodeLanguage.Shell, + environmentVariables: { KEY: 'values[$(printf injected >&2)]' }, + }) + ).rejects.toThrow('is not supported in shell arithmetic') + }) + + it('preserves shell literal arithmetic text and leaves completed arithmetic frames', async () => { + const value = 'values[$(printf injected >&2)]' + const compiled = await compileCodePlaceholders({ + code: [ + 'printf "%s\\n" "{{KEY}}"', + "printf '%s\\n' '$(( {{KEY}} ))'", + "printf '%s\\n' '$[ {{KEY}} ]'", + 'printf "%s\\n" "$(printf %s "{{KEY}}")"', + 'printf "%s\\n" "$(( 1 + 1 )){{KEY}}"', + 'printf "%s\\n" "$[ values[0] + 2 ]{{KEY}}"', + '(( total = 2 )); printf "%s\\n" "{{KEY}}"', + 'cat < { + const code = 'total=$(( {{MISSING}} + {{KEY}} ))' + await expect(analyzeCodePlaceholders(code, CodeLanguage.Shell)).resolves.toEqual([ + 'MISSING', + 'KEY', + ]) + const compiled = await compileCodePlaceholders({ code, language: CodeLanguage.Shell }) + expect(compiled.code).toBe(code) + }) + it('renders quoted shell heredocs nested in double-quoted command substitutions', async () => { const compiled = await compileCodePlaceholders({ code: [ diff --git a/apps/sim/lib/execution/code-placeholders/shell.ts b/apps/sim/lib/execution/code-placeholders/shell.ts index 95677620ef2..efa1660a309 100644 --- a/apps/sim/lib/execution/code-placeholders/shell.ts +++ b/apps/sim/lib/execution/code-placeholders/shell.ts @@ -31,11 +31,14 @@ interface ShellScanFrame { kind: 'root' | 'command' | 'arithmetic' | 'backtick' quote: ShellQuote parenthesisDepth: number + bracketDepth?: number literalRoot: boolean } interface ShellOccurrenceContext { quote: ShellQuote + /** Includes nested command substitutions whose output can become an arithmetic operand. */ + arithmetic?: boolean unsupported?: 'escaped sequence' } @@ -445,10 +448,11 @@ function isShellAssignmentName(code: string, occurrence: CodePlaceholderOccurren function getUnsupportedShellPosition( code: string, occurrence: CodePlaceholderOccurrence, - quote: ShellQuote + context: ShellOccurrenceContext ): string | undefined { + if (context.arithmetic) return 'in shell arithmetic' if (code[occurrence.start - 1] === '$') return 'immediately after "$"' - if (quote !== 'none') return undefined + if (context.quote !== 'none') return undefined const lineStart = Math.max( code.lastIndexOf('\n', occurrence.start - 1), @@ -488,6 +492,7 @@ function collectShellOccurrenceContexts( { kind: 'root', quote: 'none', parenthesisDepth: 0, literalRoot }, ] let skippedRangeIndex = 0 + let arithmeticDepth = 0 for (let index = start; index < end; ) { const frame = frames.at(-1) @@ -507,7 +512,7 @@ function collectShellOccurrenceContexts( const occurrence = occurrenceByStart.get(index) if (occurrence) { - contexts.set(occurrence, { quote: frame.quote }) + contexts.set(occurrence, { quote: frame.quote, arithmetic: arithmeticDepth > 0 }) index = occurrence.end continue } @@ -533,6 +538,24 @@ function collectShellOccurrenceContexts( } continue } + const arithmeticExpansion = + character === '$' && + ((code[index + 1] === '(' && code[index + 2] === '(') || code[index + 1] === '[') + const arithmeticCommand = + frame.quote === 'none' && !frame.literalRoot && shellArithmeticCommandStarts(code, index) + if (arithmeticExpansion || arithmeticCommand) { + const brackets = arithmeticExpansion && code[index + 1] === '[' + frames.push({ + kind: 'arithmetic', + quote: 'none', + parenthesisDepth: brackets ? 0 : 2, + ...(brackets ? { bracketDepth: 1 } : {}), + literalRoot: false, + }) + arithmeticDepth += 1 + index += arithmeticExpansion && !brackets ? 3 : 2 + continue + } if (frame.quote === 'double') { if (character === '\\') { const escaped = occurrenceByStart.get(index + 1) @@ -572,7 +595,7 @@ function collectShellOccurrenceContexts( index += 1 continue } - if (!frame.literalRoot && shellCommentStarts(code, index)) { + if (frame.kind !== 'arithmetic' && !frame.literalRoot && shellCommentStarts(code, index)) { const newline = code.indexOf('\n', index) index = newline === -1 || newline >= end ? end : newline + 1 continue @@ -622,14 +645,29 @@ function collectShellOccurrenceContexts( index += 1 continue } - if (frame.kind === 'command' && character === '(') { + if (frame.kind === 'arithmetic' && frame.bracketDepth !== undefined) { + if (character === '[') frame.bracketDepth += 1 + if (character === ']') { + frame.bracketDepth -= 1 + if (frame.bracketDepth === 0) { + frames.pop() + arithmeticDepth -= 1 + } + } + index += 1 + continue + } + if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === '(') { frame.parenthesisDepth += 1 index += 1 continue } - if (frame.kind === 'command' && character === ')') { + if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === ')') { frame.parenthesisDepth -= 1 - if (frame.parenthesisDepth === 0) frames.pop() + if (frame.parenthesisDepth === 0) { + frames.pop() + if (frame.kind === 'arithmetic') arithmeticDepth -= 1 + } index += 1 continue } @@ -849,7 +887,7 @@ export async function compileShellPlaceholders( const unsupportedPosition = getUnsupportedShellPosition( input.code, occurrence, - occurrenceContext.quote + occurrenceContext ) if (unsupportedPosition) { if (context.hasValue(occurrence.name)) { @@ -904,7 +942,7 @@ export async function compileShellPlaceholders( const unsupportedPosition = getUnsupportedShellPosition( input.code, occurrence, - occurrenceContext.quote + occurrenceContext ) if (unsupportedPosition) { if (context.hasValue(occurrence.name)) {