Skip to content

Commit 2f3e963

Browse files
committed
fix(code-placeholders): harden shell placeholder interpolation in arithmetic contexts
1 parent 14e6c0a commit 2f3e963

3 files changed

Lines changed: 959 additions & 191 deletions

File tree

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

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,78 @@ describe('code placeholder compiler', () => {
10531053
'total=$(( $(( 1 + 1 )) + {{KEY}} ))',
10541054
'cat <<PAYLOAD\n$(( {{KEY}} * 2 ))\nPAYLOAD',
10551055
'cat <<PAYLOAD\n$[ {{KEY}} * 2 ]\nPAYLOAD',
1056+
'echo $(( $(cat <<EOF\n{{KEY}}\nEOF\n) + 1 ))',
1057+
"echo $(( $(cat <<'EOF'\n{{KEY}}\nEOF\n) + 1 ))",
1058+
"echo $(( $(cat <<-'EOF'\n\t{{KEY}}\n\tEOF\n) + 1 ))",
1059+
'[[ {{KEY}} -eq 0 ]] && echo zero',
1060+
'[[ "{{KEY}}" -eq 0 ]] && echo zero',
1061+
'if [[ 0 -lt {{KEY}} ]]; then echo positive; fi',
1062+
'[[ -n x && ( "{{KEY}}" -ge 1 ) ]]',
1063+
'[[ $(printf "%s" "{{KEY}}") -ne 0 ]]',
1064+
'[[ $(printf "%s" "{{KEY}}"; echo 1) -le 0 ]]',
1065+
'[[ $(cat <<EOF\n{{KEY}}\nEOF\n) -gt 0 ]]',
1066+
'let "x={{KEY}}"',
1067+
'let x={{KEY}}+1',
1068+
'declare -i x="{{KEY}}"',
1069+
'typeset -i x={{KEY}}',
1070+
'f() { local -i x="{{KEY}}"; }',
1071+
'declare -ai values=("{{KEY}}")',
1072+
'declare -x -i x="{{KEY}}"',
1073+
'let x="$(printf "%s" "{{KEY}}")"',
1074+
'declare -i x="$(printf "%s" "{{KEY}}")"',
1075+
'>/dev/null let x="{{KEY}}"',
1076+
'2>/dev/null let x="{{KEY}}"',
1077+
'builtin let x="{{KEY}}"',
1078+
'command let x="{{KEY}}"',
1079+
'declare "-i" n="{{KEY}}"',
1080+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1081+
'[[ ${missing:-{{KEY}}} -eq 0 ]]',
1082+
'declare -i n; echo "$(declare +i n)"; n="{{KEY}}"',
1083+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1084+
'printf "%s" "${missing:-{text}"; let x="{{KEY}}"',
1085+
'declare -i n; n="$(printf "%s" "{{KEY}}")"',
1086+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1087+
'declare -i n; n="${missing:-{{KEY}}}"',
1088+
'declare -i n; declare n="{{KEY}}"',
1089+
'> /dev/null let x="{{KEY}}"',
1090+
'let x="$(cat <<EOF\n{{KEY}}\nEOF\n)"',
1091+
'unset "a[{{KEY}}]"',
1092+
'unset a[{{KEY}}]',
1093+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1094+
'declare -A m=([key]=abc); printf "%s\\n" "${m[key]:{{KEY}}}"',
1095+
'echo $(( 1 < {{KEY}} ))',
1096+
'let x=1 >/dev/null {{KEY}}',
1097+
'a=([{{KEY}}]=text)',
1098+
"unset 'a[{{KEY}}]'",
1099+
'le\\\nt "x={{KEY}}"',
1100+
'local -A a; a[{{KEY}}]=1',
1101+
'declare -A m; unset m; m[{{KEY}}]=1',
1102+
'declare -A m; unset m; declare -a m; m[{{KEY}}]=1',
1103+
'(declare -A m); m[{{KEY}}]=1',
1104+
'a[{{KEY}}]=1',
1105+
'a[{{KEY}}]+=1',
1106+
'declare -i n; n="{{KEY}}"',
1107+
'declare -i n\nn="{{KEY}}"',
1108+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1109+
'a=(1 2); echo "${a[{{KEY}}]}"',
1110+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1111+
'a=(1 2); echo "${a[0]:{{KEY}}}"',
1112+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1113+
'a=(1 2); echo "${a[0]:0:{{KEY}}}"',
1114+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1115+
's=abc; echo "${s:{{KEY}}}"',
1116+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1117+
's=abc; echo "${s:0:{{KEY}}}"',
1118+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1119+
's=abc; echo "${s: -1:{{KEY}}}"',
1120+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1121+
'set -- a b; echo "${@:{{KEY}}}"',
1122+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1123+
's=abc; printf "%s\\n" "${missing:-"${s:{{KEY}}}"}"',
1124+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1125+
'cat <<PAYLOAD\n${a[{{KEY}}]}\nPAYLOAD',
1126+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1127+
'cat <<PAYLOAD\n${s:{{KEY}}}\nPAYLOAD',
10561128
])('rejects shell placeholders whose values enter arithmetic: %s', async (code) => {
10571129
await expect(
10581130
compileCodePlaceholders({
@@ -1087,6 +1159,96 @@ describe('code placeholder compiler', () => {
10871159
)
10881160
})
10891161

1162+
it.each([
1163+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1164+
'printf "%s\\n" "${missing:-https://{{KEY}}}"',
1165+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1166+
'printf "%s\\n" "${missing:-items[{{KEY}}]}"',
1167+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1168+
's=abc; printf "%s\\n" "${s#[{{KEY}}]}"',
1169+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1170+
's=abc; printf "%s\\n" "${s/[{{KEY}}]/x}"',
1171+
'f() { local -i n; }; n="{{KEY}}"',
1172+
'PREFIX="$(printf "%s" "{{KEY}}")" let total=2',
1173+
'printf "%s" a[{{KEY}}]=1',
1174+
'declare -i n; printf "%s" n={{KEY}}',
1175+
'declare -i n; declare +i n; n="{{KEY}}"',
1176+
'declare -i n; n=1 printf "%s" "{{KEY}}"',
1177+
'declare -i n; n=5 text="{{KEY}}"',
1178+
'declare -i +i n="{{KEY}}"',
1179+
'declare -i n; echo "$(declare +i n; n=\'{{KEY}}\'; printf "%s" "$n")"',
1180+
'$(let x=1 <<EOF\n{{KEY}}\nEOF\n)',
1181+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1182+
'declare -A m; m[{{KEY}}]=x; printf "%s\\n" "${m[{{KEY}}]}"',
1183+
'declare -A m; printf "%s\\n" "${m[{{KEY}}]}"',
1184+
'declare -iA m; m[{{KEY}}]=1',
1185+
'read -p "items[{{KEY}}]" name',
1186+
'a=([{{KEY}}])',
1187+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1188+
'printf "%s\\n" "${missing:- # {{KEY}}}"',
1189+
'declare -A m; declare -A m; m[{{KEY}}]=1',
1190+
'let x=1 >"{{KEY}}"',
1191+
'let x=1 > {{KEY}}',
1192+
'declare -i n=1 >"{{KEY}}"',
1193+
'[[ "{{KEY}}" == "-eq" ]]',
1194+
])('compiles parameter and prefix text that is not an arithmetic operand: %s', async (code) => {
1195+
// These forms use the value as pattern, default, or a shielded prefix — never arithmetic — so a
1196+
// conservative scanner must not reject them. Each would fail compilation if wrongly rejected.
1197+
await expect(
1198+
compileCodePlaceholders({
1199+
code,
1200+
language: CodeLanguage.Shell,
1201+
environmentVariables: { KEY: 'values[$(printf injected >&2)]' },
1202+
})
1203+
).resolves.toBeDefined()
1204+
})
1205+
1206+
it('keeps literal single quotes inside a double-quoted default expansion', async () => {
1207+
const compiled = await compileCodePlaceholders({
1208+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1209+
code: 'printf "%s\\n" "${missing:-\'{{KEY}}\'}"',
1210+
language: CodeLanguage.Shell,
1211+
environmentVariables: { KEY: 'hello world' },
1212+
})
1213+
expect(executeShell(compiled.code, compiled.bindings)).toBe("'hello world'\n")
1214+
})
1215+
1216+
it('compiles shell placeholders beside arithmetic that never evaluates them', async () => {
1217+
const value = 'values[$(printf injected >&2)]'
1218+
const compiled = await compileCodePlaceholders({
1219+
code: [
1220+
'[ "{{KEY}}" -eq 0 ] 2>/dev/null || printf "%s\\n" not-zero',
1221+
'[[ "{{KEY}}" == values* && 1 -eq 1 ]] && printf "%s\\n" matched',
1222+
'let total=1+1; printf "%s\\n" "{{KEY}}"',
1223+
'f() { local copy="{{KEY}}"; printf "%s\\n" "$copy"; }; f',
1224+
'declare copy="{{KEY}}"; printf "%s\\n" "$copy"',
1225+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1226+
'printf "%s\\n" "${missing:-{{KEY}}}"',
1227+
// biome-ignore lint/suspicious/noTemplateCurlyInString: shell parameter expansion, not a JS template
1228+
'a=(x y); printf "%s\\n" "${a[1]}{{KEY}}"',
1229+
'printf "%s\\n" "let {{KEY}}"',
1230+
'printf "%s\\n" "items[{{KEY}}]"',
1231+
'PREFIX="{{KEY}}" let total=2',
1232+
'# declare -i n',
1233+
'n="{{KEY}}"; printf "%s\\n" "$n"',
1234+
'let total=1 <<EOF',
1235+
'{{KEY}}',
1236+
'EOF',
1237+
'printf "%s\\n" "$total"',
1238+
'cat <<PAYLOAD',
1239+
'{{KEY}} $(( 1 + 1 ))',
1240+
'PAYLOAD',
1241+
].join('\n'),
1242+
language: CodeLanguage.Shell,
1243+
environmentVariables: { KEY: value },
1244+
})
1245+
1246+
expect(executeShell(`{\n${compiled.code}\n} 2>&1`, compiled.bindings)).toBe(
1247+
`not-zero\nmatched\n${value}\n${value}\n${value}\n${value}\ny${value}\n` +
1248+
`let ${value}\nitems[${value}]\n${value}\n1\n${value} 2\n`
1249+
)
1250+
})
1251+
10901252
it('discovers shell arithmetic placeholders without compiling missing values', async () => {
10911253
const code = 'total=$(( {{MISSING}} + {{KEY}} ))'
10921254
await expect(analyzeCodePlaceholders(code, CodeLanguage.Shell)).resolves.toEqual([

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,12 +327,12 @@ export function createOffsetRangeLookup(
327327
* The placeholders lying wholly inside `[start, end]`. Occurrences are ordered and never
328328
* overlap, so the matches are one contiguous run found by bisection.
329329
*/
330-
export function occurrencesWithin(
331-
occurrences: readonly CodePlaceholderOccurrence[],
330+
export function occurrencesWithin<T extends Pick<CodePlaceholderOccurrence, 'start' | 'end'>>(
331+
occurrences: readonly T[],
332332
start: number,
333333
end: number
334-
): CodePlaceholderOccurrence[] {
335-
const matches: CodePlaceholderOccurrence[] = []
334+
): T[] {
335+
const matches: T[] = []
336336
for (
337337
let index = partitionPoint(occurrences, (occurrence) => occurrence.start < start);
338338
index < occurrences.length && occurrences[index].end <= end;

0 commit comments

Comments
 (0)