Skip to content

Commit 7f8362f

Browse files
KJ7LNWEric Wheeler
andauthored
fix: command validation failing on shell array indexing (#3530)
Co-authored-by: Eric Wheeler <[email protected]>
1 parent 78a685a commit 7f8362f

File tree

3 files changed

+182
-4
lines changed

3 files changed

+182
-4
lines changed

webview-ui/.eslintrc.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,14 @@
44
"rules": {
55
"no-unused-vars": "off",
66
"@typescript-eslint/no-unused-vars": ["error", { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }]
7-
}
7+
},
8+
"overrides": [
9+
{
10+
"files": ["webview-ui/src/__tests__/utils/command-validation.test.ts"],
11+
"rules": {
12+
"no-template-curly-in-string": "off",
13+
"no-useless-escape": "off"
14+
}
15+
}
16+
]
817
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { parseCommand, validateCommand } from "../../utils/command-validation"
2+
3+
/**
4+
* Tests for the command-validation module
5+
*
6+
* These tests include a reproduction of a bug where the shell-quote library
7+
* used in parseCommand throws an error when parsing commands that contain
8+
* the Bash $RANDOM variable in array indexing expressions.
9+
*
10+
* Error: "Bad substitution: levels[$RANDOM"
11+
*
12+
* The issue occurs specifically with complex expressions like:
13+
* ${levels[$RANDOM % ${#levels[@]}]}
14+
*/
15+
describe("command-validation", () => {
16+
describe("parseCommand", () => {
17+
it("should correctly parse simple commands", () => {
18+
const result = parseCommand("echo hello")
19+
expect(result).toEqual(["echo hello"])
20+
})
21+
22+
it("should correctly parse commands with chaining operators", () => {
23+
const result = parseCommand("echo hello && echo world")
24+
expect(result).toEqual(["echo hello", "echo world"])
25+
})
26+
27+
it("should correctly parse commands with quotes", () => {
28+
const result = parseCommand('echo "hello world"')
29+
expect(result).toEqual(['echo "hello world"'])
30+
})
31+
32+
it("should correctly parse commands with redirections", () => {
33+
const result = parseCommand("echo hello 2>&1")
34+
expect(result).toEqual(["echo hello 2>&1"])
35+
})
36+
37+
it("should correctly parse commands with subshells", () => {
38+
const result = parseCommand("echo $(date)")
39+
expect(result).toEqual(["echo", "date"])
40+
})
41+
42+
it("should not throw an error when parsing commands with simple array indexing", () => {
43+
// Simple array indexing works fine
44+
const commandWithArrayIndex = "value=${array[$index]}"
45+
46+
expect(() => {
47+
parseCommand(commandWithArrayIndex)
48+
}).not.toThrow()
49+
})
50+
51+
it("should not throw an error when parsing commands with $RANDOM in array index", () => {
52+
// This test reproduces the specific bug reported in the error
53+
const commandWithRandom = "level=${levels[$RANDOM % ${#levels[@]}]}"
54+
55+
expect(() => {
56+
parseCommand(commandWithRandom)
57+
}).not.toThrow()
58+
})
59+
60+
it("should not throw an error with simple $RANDOM variable", () => {
61+
// Simple $RANDOM usage works fine
62+
const commandWithRandom = "echo $RANDOM"
63+
64+
expect(() => {
65+
parseCommand(commandWithRandom)
66+
}).not.toThrow()
67+
})
68+
69+
it("should not throw an error with simple array indexing using $RANDOM", () => {
70+
// Simple array indexing with $RANDOM works fine
71+
const commandWithRandomIndex = "echo ${array[$RANDOM]}"
72+
73+
expect(() => {
74+
parseCommand(commandWithRandomIndex)
75+
}).not.toThrow()
76+
})
77+
78+
it("should not throw an error with complex array indexing using $RANDOM and arithmetic", () => {
79+
// This test reproduces the exact expression from the original error
80+
const commandWithComplexRandom = "echo ${levels[$RANDOM % ${#levels[@]}]}"
81+
82+
expect(() => {
83+
parseCommand(commandWithComplexRandom)
84+
}).not.toThrow("Bad substitution")
85+
})
86+
87+
it("should not throw an error when parsing the full log generator command", () => {
88+
// This is the exact command from the original error message
89+
const logGeneratorCommand = `while true; do \\
90+
levels=(INFO WARN ERROR DEBUG); \\
91+
msgs=("User logged in" "Connection timeout" "Processing request" "Cache miss" "Database query"); \\
92+
level=\${levels[$RANDOM % \${#levels[@]}]}; \\
93+
msg=\${msgs[$RANDOM % \${#msgs[@]}]}; \\
94+
echo "\$(date '+%Y-%m-%d %H:%M:%S') [$level] $msg"; \\
95+
sleep 1; \\
96+
done`
97+
98+
// This reproduces the original error
99+
expect(() => {
100+
parseCommand(logGeneratorCommand)
101+
}).not.toThrow("Bad substitution: levels[$RANDOM")
102+
})
103+
104+
it("should not throw an error when parsing just the problematic part", () => {
105+
// This isolates just the part mentioned in the error message
106+
const problematicPart = "level=${levels[$RANDOM"
107+
108+
expect(() => {
109+
parseCommand(problematicPart)
110+
}).not.toThrow("Bad substitution")
111+
})
112+
})
113+
114+
describe("validateCommand", () => {
115+
it("should validate allowed commands", () => {
116+
const result = validateCommand("echo hello", ["echo"])
117+
expect(result).toBe(true)
118+
})
119+
120+
it("should reject disallowed commands", () => {
121+
const result = validateCommand("rm -rf /", ["echo", "ls"])
122+
expect(result).toBe(false)
123+
})
124+
125+
it("should not fail validation for commands with simple $RANDOM variable", () => {
126+
const commandWithRandom = "echo $RANDOM"
127+
128+
expect(() => {
129+
validateCommand(commandWithRandom, ["echo"])
130+
}).not.toThrow()
131+
})
132+
133+
it("should not fail validation for commands with simple array indexing using $RANDOM", () => {
134+
const commandWithRandomIndex = "echo ${array[$RANDOM]}"
135+
136+
expect(() => {
137+
validateCommand(commandWithRandomIndex, ["echo"])
138+
}).not.toThrow()
139+
})
140+
141+
it("should return false for the full log generator command due to subshell detection", () => {
142+
// This is the exact command from the original error message
143+
const logGeneratorCommand = `while true; do \\
144+
levels=(INFO WARN ERROR DEBUG); \\
145+
msgs=("User logged in" "Connection timeout" "Processing request" "Cache miss" "Database query"); \\
146+
level=\${levels[$RANDOM % \${#levels[@]}]}; \\
147+
msg=\${msgs[$RANDOM % \${#msgs[@]}]}; \\
148+
echo "\$(date '+%Y-%m-%d %H:%M:%S') [$level] $msg"; \\
149+
sleep 1; \\
150+
done`
151+
152+
// validateCommand should return false due to subshell detection
153+
// without throwing an error
154+
const result = validateCommand(logGeneratorCommand, ["while"])
155+
expect(result).toBe(false)
156+
})
157+
})
158+
})

webview-ui/src/utils/command-validation.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,25 @@ type ShellToken = string | { op: string } | { command: string }
1515
export function parseCommand(command: string): string[] {
1616
if (!command?.trim()) return []
1717

18-
// First handle PowerShell redirections by temporarily replacing them
18+
// Storage for replaced content
1919
const redirections: string[] = []
20+
const subshells: string[] = []
21+
const quotes: string[] = []
22+
const arrayIndexing: string[] = []
23+
24+
// First handle PowerShell redirections by temporarily replacing them
2025
let processedCommand = command.replace(/\d*>&\d*/g, (match) => {
2126
redirections.push(match)
2227
return `__REDIR_${redirections.length - 1}__`
2328
})
2429

30+
// Handle array indexing expressions: ${array[...]} pattern and partial expressions
31+
processedCommand = processedCommand.replace(/\$\{[^}]*\[[^\]]*(\]([^}]*\})?)?/g, (match) => {
32+
arrayIndexing.push(match)
33+
return `__ARRAY_${arrayIndexing.length - 1}__`
34+
})
35+
2536
// Then handle subshell commands
26-
const subshells: string[] = []
2737
processedCommand = processedCommand
2838
.replace(/\$\((.*?)\)/g, (_, inner) => {
2939
subshells.push(inner.trim())
@@ -35,7 +45,6 @@ export function parseCommand(command: string): string[] {
3545
})
3646

3747
// Then handle quoted strings
38-
const quotes: string[] = []
3948
processedCommand = processedCommand.replace(/"[^"]*"/g, (match) => {
4049
quotes.push(match)
4150
return `__QUOTE_${quotes.length - 1}__`
@@ -84,6 +93,8 @@ export function parseCommand(command: string): string[] {
8493
result = result.replace(/__QUOTE_(\d+)__/g, (_, i) => quotes[parseInt(i)])
8594
// Restore redirections
8695
result = result.replace(/__REDIR_(\d+)__/g, (_, i) => redirections[parseInt(i)])
96+
// Restore array indexing expressions
97+
result = result.replace(/__ARRAY_(\d+)__/g, (_, i) => arrayIndexing[parseInt(i)])
8798
return result
8899
})
89100
}

0 commit comments

Comments
 (0)