Skip to content

Commit 2c8eeaf

Browse files
fix(code-placeholders): reject shell arithmetic placeholders (#8628)
1 parent a865f47 commit 2c8eeaf

2 files changed

Lines changed: 105 additions & 9 deletions

File tree

‎apps/sim/lib/execution/code-placeholders/compiler.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,64 @@ describe('code placeholder compiler', () => {
10391039
}
10401040
})
10411041

1042+
it.each([
1043+
'total=$(( {{KEY}} * 2 ))',
1044+
'printf "%s" "$(( {{KEY}} * 2 ))"',
1045+
'total=$(( "{{KEY}}" * 2 ))',
1046+
'total=$[ {{KEY}} * 2 ]',
1047+
'printf "%s" "$[ {{KEY}} * 2 ]"',
1048+
'total=$[ values[0] + {{KEY}} ]',
1049+
'(( total = {{KEY}} * 2 ))',
1050+
'for (( i = {{KEY}}; i < 2; i++ )); do :; done',
1051+
'total=$(( $(printf "%s" "{{KEY}}") * 2 ))',
1052+
'total=$(( `printf "%s" "{{KEY}}"` * 2 ))',
1053+
'total=$(( $(( 1 + 1 )) + {{KEY}} ))',
1054+
'cat <<PAYLOAD\n$(( {{KEY}} * 2 ))\nPAYLOAD',
1055+
'cat <<PAYLOAD\n$[ {{KEY}} * 2 ]\nPAYLOAD',
1056+
])('rejects shell placeholders whose values enter arithmetic: %s', async (code) => {
1057+
await expect(
1058+
compileCodePlaceholders({
1059+
code,
1060+
language: CodeLanguage.Shell,
1061+
environmentVariables: { KEY: 'values[$(printf injected >&2)]' },
1062+
})
1063+
).rejects.toThrow('is not supported in shell arithmetic')
1064+
})
1065+
1066+
it('preserves shell literal arithmetic text and leaves completed arithmetic frames', async () => {
1067+
const value = 'values[$(printf injected >&2)]'
1068+
const compiled = await compileCodePlaceholders({
1069+
code: [
1070+
'printf "%s\\n" "{{KEY}}"',
1071+
"printf '%s\\n' '$(( {{KEY}} ))'",
1072+
"printf '%s\\n' '$[ {{KEY}} ]'",
1073+
'printf "%s\\n" "$(printf %s "{{KEY}}")"',
1074+
'printf "%s\\n" "$(( 1 + 1 )){{KEY}}"',
1075+
'printf "%s\\n" "$[ values[0] + 2 ]{{KEY}}"',
1076+
'(( total = 2 )); printf "%s\\n" "{{KEY}}"',
1077+
'cat <<PAYLOAD',
1078+
'(( {{KEY}} ))',
1079+
'PAYLOAD',
1080+
].join('\n'),
1081+
language: CodeLanguage.Shell,
1082+
environmentVariables: { KEY: value },
1083+
})
1084+
1085+
expect(executeShell(compiled.code, compiled.bindings)).toBe(
1086+
`${value}\n$(( ${value} ))\n$[ ${value} ]\n${value}\n2${value}\n2${value}\n${value}\n(( ${value} ))\n`
1087+
)
1088+
})
1089+
1090+
it('discovers shell arithmetic placeholders without compiling missing values', async () => {
1091+
const code = 'total=$(( {{MISSING}} + {{KEY}} ))'
1092+
await expect(analyzeCodePlaceholders(code, CodeLanguage.Shell)).resolves.toEqual([
1093+
'MISSING',
1094+
'KEY',
1095+
])
1096+
const compiled = await compileCodePlaceholders({ code, language: CodeLanguage.Shell })
1097+
expect(compiled.code).toBe(code)
1098+
})
1099+
10421100
it('renders quoted shell heredocs nested in double-quoted command substitutions', async () => {
10431101
const compiled = await compileCodePlaceholders({
10441102
code: [

‎apps/sim/lib/execution/code-placeholders/shell.ts‎

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,14 @@ interface ShellScanFrame {
3131
kind: 'root' | 'command' | 'arithmetic' | 'backtick'
3232
quote: ShellQuote
3333
parenthesisDepth: number
34+
bracketDepth?: number
3435
literalRoot: boolean
3536
}
3637

3738
interface ShellOccurrenceContext {
3839
quote: ShellQuote
40+
/** Includes nested command substitutions whose output can become an arithmetic operand. */
41+
arithmetic?: boolean
3942
unsupported?: 'escaped sequence'
4043
}
4144

@@ -445,10 +448,11 @@ function isShellAssignmentName(code: string, occurrence: CodePlaceholderOccurren
445448
function getUnsupportedShellPosition(
446449
code: string,
447450
occurrence: CodePlaceholderOccurrence,
448-
quote: ShellQuote
451+
context: ShellOccurrenceContext
449452
): string | undefined {
453+
if (context.arithmetic) return 'in shell arithmetic'
450454
if (code[occurrence.start - 1] === '$') return 'immediately after "$"'
451-
if (quote !== 'none') return undefined
455+
if (context.quote !== 'none') return undefined
452456

453457
const lineStart = Math.max(
454458
code.lastIndexOf('\n', occurrence.start - 1),
@@ -488,6 +492,7 @@ function collectShellOccurrenceContexts(
488492
{ kind: 'root', quote: 'none', parenthesisDepth: 0, literalRoot },
489493
]
490494
let skippedRangeIndex = 0
495+
let arithmeticDepth = 0
491496

492497
for (let index = start; index < end; ) {
493498
const frame = frames.at(-1)
@@ -507,7 +512,7 @@ function collectShellOccurrenceContexts(
507512

508513
const occurrence = occurrenceByStart.get(index)
509514
if (occurrence) {
510-
contexts.set(occurrence, { quote: frame.quote })
515+
contexts.set(occurrence, { quote: frame.quote, arithmetic: arithmeticDepth > 0 })
511516
index = occurrence.end
512517
continue
513518
}
@@ -533,6 +538,24 @@ function collectShellOccurrenceContexts(
533538
}
534539
continue
535540
}
541+
const arithmeticExpansion =
542+
character === '$' &&
543+
((code[index + 1] === '(' && code[index + 2] === '(') || code[index + 1] === '[')
544+
const arithmeticCommand =
545+
frame.quote === 'none' && !frame.literalRoot && shellArithmeticCommandStarts(code, index)
546+
if (arithmeticExpansion || arithmeticCommand) {
547+
const brackets = arithmeticExpansion && code[index + 1] === '['
548+
frames.push({
549+
kind: 'arithmetic',
550+
quote: 'none',
551+
parenthesisDepth: brackets ? 0 : 2,
552+
...(brackets ? { bracketDepth: 1 } : {}),
553+
literalRoot: false,
554+
})
555+
arithmeticDepth += 1
556+
index += arithmeticExpansion && !brackets ? 3 : 2
557+
continue
558+
}
536559
if (frame.quote === 'double') {
537560
if (character === '\\') {
538561
const escaped = occurrenceByStart.get(index + 1)
@@ -572,7 +595,7 @@ function collectShellOccurrenceContexts(
572595
index += 1
573596
continue
574597
}
575-
if (!frame.literalRoot && shellCommentStarts(code, index)) {
598+
if (frame.kind !== 'arithmetic' && !frame.literalRoot && shellCommentStarts(code, index)) {
576599
const newline = code.indexOf('\n', index)
577600
index = newline === -1 || newline >= end ? end : newline + 1
578601
continue
@@ -622,14 +645,29 @@ function collectShellOccurrenceContexts(
622645
index += 1
623646
continue
624647
}
625-
if (frame.kind === 'command' && character === '(') {
648+
if (frame.kind === 'arithmetic' && frame.bracketDepth !== undefined) {
649+
if (character === '[') frame.bracketDepth += 1
650+
if (character === ']') {
651+
frame.bracketDepth -= 1
652+
if (frame.bracketDepth === 0) {
653+
frames.pop()
654+
arithmeticDepth -= 1
655+
}
656+
}
657+
index += 1
658+
continue
659+
}
660+
if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === '(') {
626661
frame.parenthesisDepth += 1
627662
index += 1
628663
continue
629664
}
630-
if (frame.kind === 'command' && character === ')') {
665+
if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === ')') {
631666
frame.parenthesisDepth -= 1
632-
if (frame.parenthesisDepth === 0) frames.pop()
667+
if (frame.parenthesisDepth === 0) {
668+
frames.pop()
669+
if (frame.kind === 'arithmetic') arithmeticDepth -= 1
670+
}
633671
index += 1
634672
continue
635673
}
@@ -849,7 +887,7 @@ export async function compileShellPlaceholders(
849887
const unsupportedPosition = getUnsupportedShellPosition(
850888
input.code,
851889
occurrence,
852-
occurrenceContext.quote
890+
occurrenceContext
853891
)
854892
if (unsupportedPosition) {
855893
if (context.hasValue(occurrence.name)) {
@@ -904,7 +942,7 @@ export async function compileShellPlaceholders(
904942
const unsupportedPosition = getUnsupportedShellPosition(
905943
input.code,
906944
occurrence,
907-
occurrenceContext.quote
945+
occurrenceContext
908946
)
909947
if (unsupportedPosition) {
910948
if (context.hasValue(occurrence.name)) {

0 commit comments

Comments
 (0)