Skip to content

Commit c7ebf8c

Browse files
committed
fix(shell): reject arithmetic array subscript placeholders
1 parent 0b3e655 commit c7ebf8c

2 files changed

Lines changed: 103 additions & 8 deletions

File tree

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,15 @@ describe('code placeholder compiler', () => {
10261026
'echo "$[ values[0] + {{KEY}} ]"',
10271027
'cat <<EOF\n$(( {{KEY}} * 2 ))\nEOF',
10281028
'cat <<EOF\n$[ {{KEY}} * 2 ]\nEOF',
1029+
'values[{{KEY}}]=x',
1030+
'values[{{KEY}}]+=x',
1031+
'values[ 1 + {{KEY}} ]=x',
1032+
'values[$(printf %s "{{KEY}}")]=x',
1033+
'values=([{{KEY}}]=x)',
1034+
'values+=([{{KEY}}]=x)',
1035+
`echo "\${values[{{KEY}}]}"`,
1036+
`echo "\${#values[{{KEY}}]}"`,
1037+
`cat <<EOF\n\${values[{{KEY}}]}\nEOF`,
10291038
])('rejects shell placeholders that feed arithmetic evaluation: %s', async (code) => {
10301039
await expect(
10311040
compileCodePlaceholders({
@@ -1064,6 +1073,25 @@ describe('code placeholder compiler', () => {
10641073
)
10651074
})
10661075

1076+
it('keeps bracket words and array values outside arithmetic positions', async () => {
1077+
const compiled = await compileCodePlaceholders({
1078+
code: [
1079+
'printf "%s\\n" "values[{{KEY}}]=literal" values[{{KEY}}]',
1080+
'values=("{{KEY}}" item[{{KEY}}])',
1081+
'values[0]="{{KEY}}"',
1082+
`printf "%s\\n" "\${values[@]}"`,
1083+
'cat <<EOF',
1084+
'values[{{KEY}}]=literal',
1085+
'EOF',
1086+
].join('\n'),
1087+
language: CodeLanguage.Shell,
1088+
environmentVariables: { KEY: 'word' },
1089+
})
1090+
expect(executeShell(compiled.code, compiled.bindings)).toBe(
1091+
'values[word]=literal\nvalues[word]\nword\nitem[word]\nvalues[word]=literal\n'
1092+
)
1093+
})
1094+
10671095
it.each([
10681096
['unquoted', "cat <<EOF >/dev/null\nToday's report\nEOF"],
10691097
['quoted', "cat <<'EOF' >/dev/null\nToday's report\nEOF"],

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

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,22 @@ interface HeredocDeclaration {
2626
type ShellQuote = 'none' | 'single' | 'double' | 'ansi'
2727

2828
interface ShellScanFrame {
29-
kind: 'root' | 'command' | 'arithmetic' | 'backtick'
29+
kind: 'root' | 'command' | 'arithmetic' | 'backtick' | 'array'
3030
quote: ShellQuote
3131
parenthesisDepth: number
3232
bracketDepth?: number
33+
/** A bare name[index] is arithmetic only when its closing bracket is followed by assignment. */
34+
arrayAssignment?: boolean
3335
literalRoot: boolean
3436
}
3537

3638
interface ShellOccurrenceContext {
3739
quote: ShellQuote
38-
/** Enclosing arithmetic also re-evaluates the output of a nested command substitution. */
39-
arithmetic?: boolean
40+
/**
41+
* Enclosing arithmetic also re-evaluates nested substitutions. Keep frame references so a
42+
* bare subscript can be classified when its closing bracket confirms an assignment.
43+
*/
44+
arithmeticFrames?: ShellScanFrame[]
4045
unsupported?: 'escaped sequence'
4146
}
4247

@@ -98,6 +103,49 @@ function readShellExpansionStart(
98103
}
99104
}
100105

106+
function readShellArrayStart(
107+
code: string,
108+
index: number,
109+
parent: ShellScanFrame
110+
): ShellScanFrame | undefined {
111+
const character = code[index]
112+
if (character !== '[' && character !== '(') return undefined
113+
114+
let nameEnd = index
115+
if (character === '(') {
116+
if (code[index - 1] !== '=') return undefined
117+
nameEnd -= 1
118+
if (code[nameEnd - 1] === '+') nameEnd -= 1
119+
}
120+
let nameStart = nameEnd
121+
while (nameStart > 0 && /[A-Za-z0-9_]/.test(code[nameStart - 1])) nameStart -= 1
122+
const hasName = nameStart < nameEnd && /[A-Za-z_]/.test(code[nameStart])
123+
const parameterStart =
124+
code[nameStart - 1] === '#' || code[nameStart - 1] === '!' ? nameStart - 3 : nameStart - 2
125+
const parameter =
126+
character === '[' &&
127+
hasName &&
128+
code.slice(parameterStart, parameterStart + 2) === '${' &&
129+
!isBackslashEscaped(code, parameterStart)
130+
const wordStart = nameStart === 0 || /\s|[;&|()]/.test(code[nameStart - 1])
131+
const assignment =
132+
parent.quote === 'none' &&
133+
!parent.literalRoot &&
134+
wordStart &&
135+
(hasName || (character === '[' && parent.kind === 'array'))
136+
if (!parameter && !assignment) return undefined
137+
138+
return {
139+
kind: character === '[' ? 'arithmetic' : 'array',
140+
quote: 'none',
141+
parenthesisDepth: character === '(' ? 1 : 0,
142+
...(character === '['
143+
? { bracketDepth: 1, ...(!parameter ? { arrayAssignment: false } : {}) }
144+
: {}),
145+
literalRoot: false,
146+
}
147+
}
148+
101149
function decodeAnsiCCharacter(code: string, index: number): { value: string; end: number } {
102150
const character = code[index]
103151
const simple: Record<string, string> = {
@@ -435,7 +483,9 @@ function getUnsupportedShellPosition(
435483
occurrence: CodePlaceholderOccurrence,
436484
context: ShellOccurrenceContext
437485
): string | undefined {
438-
if (context.arithmetic) return 'in a shell arithmetic expression'
486+
if (context.arithmeticFrames?.some((frame) => frame.arrayAssignment !== false)) {
487+
return 'in a shell arithmetic expression'
488+
}
439489
if (code[occurrence.start - 1] === '$') return 'immediately after "$"'
440490
if (context.quote !== 'none') return undefined
441491

@@ -483,7 +533,7 @@ function collectShellOccurrenceContexts(
483533
if (occurrence) {
484534
contexts.set(occurrence, {
485535
quote: frame.quote,
486-
arithmetic: frames.some((candidate) => candidate.kind === 'arithmetic'),
536+
arithmeticFrames: frames.filter((candidate) => candidate.kind === 'arithmetic'),
487537
})
488538
index = occurrence.end
489539
continue
@@ -515,6 +565,12 @@ function collectShellOccurrenceContexts(
515565
index,
516566
frame.quote === 'none' && !frame.literalRoot && frame.kind !== 'arithmetic'
517567
)
568+
const array = readShellArrayStart(code, index, frame)
569+
if (array) {
570+
frames.push(array)
571+
index += 1
572+
continue
573+
}
518574
if (frame.quote === 'double') {
519575
if (character === '\\') {
520576
const escaped = occurrenceByStart.get(index + 1)
@@ -598,17 +654,28 @@ function collectShellOccurrenceContexts(
598654
if (character === '[') frame.bracketDepth += 1
599655
if (character === ']') {
600656
frame.bracketDepth -= 1
601-
if (frame.bracketDepth === 0) frames.pop()
657+
if (frame.bracketDepth === 0) {
658+
if (frame.arrayAssignment !== undefined) {
659+
frame.arrayAssignment = code[index + 1] === '=' || code.startsWith('+=', index + 1)
660+
}
661+
frames.pop()
662+
}
602663
}
603664
index += 1
604665
continue
605666
}
606-
if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === '(') {
667+
if (
668+
(frame.kind === 'command' || frame.kind === 'arithmetic' || frame.kind === 'array') &&
669+
character === '('
670+
) {
607671
frame.parenthesisDepth += 1
608672
index += 1
609673
continue
610674
}
611-
if ((frame.kind === 'command' || frame.kind === 'arithmetic') && character === ')') {
675+
if (
676+
(frame.kind === 'command' || frame.kind === 'arithmetic' || frame.kind === 'array') &&
677+
character === ')'
678+
) {
612679
frame.parenthesisDepth -= 1
613680
if (frame.parenthesisDepth === 0) frames.pop()
614681
index += 1

0 commit comments

Comments
 (0)