2025-07-25 12:25:32 -07:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
2025-10-16 17:25:30 -07:00
import {
expect ,
describe ,
it ,
beforeEach ,
beforeAll ,
vi ,
afterEach ,
} from 'vitest' ;
2025-07-25 12:25:32 -07:00
import {
2025-08-17 00:02:54 -04:00
escapeShellArg ,
2025-07-25 12:25:32 -07:00
getCommandRoots ,
2025-08-17 00:02:54 -04:00
getShellConfiguration ,
2025-10-16 17:25:30 -07:00
initializeShellParsers ,
2025-07-25 12:25:32 -07:00
stripShellWrapper ,
2026-01-02 11:36:59 -08:00
hasRedirection ,
2025-07-25 12:25:32 -07:00
} from './shell-utils.js' ;
2025-08-17 00:02:54 -04:00
const mockPlatform = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
2025-08-27 02:17:43 +10:00
const mockHomedir = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
2025-08-17 00:02:54 -04:00
vi . mock ( 'os' , ( ) = > ( {
default : {
platform : mockPlatform ,
2025-08-27 02:17:43 +10:00
homedir : mockHomedir ,
2025-08-17 00:02:54 -04:00
} ,
platform : mockPlatform ,
2025-08-27 02:17:43 +10:00
homedir : mockHomedir ,
2025-08-17 00:02:54 -04:00
} ) ) ;
2026-01-02 11:36:59 -08:00
const mockSpawnSync = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
vi . mock ( 'node:child_process' , ( ) = > ( {
spawnSync : mockSpawnSync ,
spawn : vi.fn ( ) ,
} ) ) ;
2025-08-17 00:02:54 -04:00
const mockQuote = vi . hoisted ( ( ) = > vi . fn ( ) ) ;
vi . mock ( 'shell-quote' , ( ) = > ( {
quote : mockQuote ,
} ) ) ;
2025-10-16 17:25:30 -07:00
const isWindowsRuntime = process . platform === 'win32' ;
const describeWindowsOnly = isWindowsRuntime ? describe : describe.skip ;
beforeAll ( async ( ) = > {
mockPlatform . mockReturnValue ( 'linux' ) ;
await initializeShellParsers ( ) ;
} ) ;
2025-07-25 12:25:32 -07:00
2025-07-27 02:00:26 -04:00
beforeEach ( ( ) = > {
2025-08-17 00:02:54 -04:00
mockPlatform . mockReturnValue ( 'linux' ) ;
mockQuote . mockImplementation ( ( args : string [ ] ) = >
args . map ( ( arg ) = > ` ' ${ arg } ' ` ) . join ( ' ' ) ,
) ;
2026-01-02 11:36:59 -08:00
mockSpawnSync . mockReturnValue ( {
stdout : Buffer.from ( '' ) ,
stderr : Buffer.from ( '' ) ,
status : 0 ,
error : undefined ,
} ) ;
2025-07-27 02:00:26 -04:00
} ) ;
2025-07-25 12:25:32 -07:00
2025-08-17 00:02:54 -04:00
afterEach ( ( ) = > {
vi . clearAllMocks ( ) ;
} ) ;
2025-07-25 12:25:32 -07:00
describe ( 'getCommandRoots' , ( ) = > {
it ( 'should return a single command' , ( ) = > {
2025-07-27 02:00:26 -04:00
expect ( getCommandRoots ( 'ls -l' ) ) . toEqual ( [ 'ls' ] ) ;
2025-07-25 12:25:32 -07:00
} ) ;
2025-07-27 02:00:26 -04:00
it ( 'should handle paths and return the binary name' , ( ) = > {
expect ( getCommandRoots ( '/usr/local/bin/node script.js' ) ) . toEqual ( [ 'node' ] ) ;
2025-07-25 12:25:32 -07:00
} ) ;
2025-07-27 02:00:26 -04:00
it ( 'should return an empty array for an empty string' , ( ) = > {
expect ( getCommandRoots ( '' ) ) . toEqual ( [ ] ) ;
2025-07-25 12:25:32 -07:00
} ) ;
it ( 'should handle a mix of operators' , ( ) = > {
2025-07-27 02:00:26 -04:00
const result = getCommandRoots ( 'a;b|c&&d||e&f' ) ;
expect ( result ) . toEqual ( [ 'a' , 'b' , 'c' , 'd' , 'e' , 'f' ] ) ;
2025-07-25 12:25:32 -07:00
} ) ;
2025-07-27 02:00:26 -04:00
it ( 'should correctly parse a chained command with quotes' , ( ) = > {
const result = getCommandRoots ( 'echo "hello" && git commit -m "feat"' ) ;
expect ( result ) . toEqual ( [ 'echo' , 'git' ] ) ;
2025-07-25 12:25:32 -07:00
} ) ;
2025-10-16 17:25:30 -07:00
it ( 'should include nested command substitutions' , ( ) = > {
const result = getCommandRoots ( 'echo $(badCommand --danger)' ) ;
expect ( result ) . toEqual ( [ 'echo' , 'badCommand' ] ) ;
} ) ;
it ( 'should include process substitutions' , ( ) = > {
const result = getCommandRoots ( 'diff <(ls) <(ls -a)' ) ;
expect ( result ) . toEqual ( [ 'diff' , 'ls' , 'ls' ] ) ;
} ) ;
it ( 'should include backtick substitutions' , ( ) = > {
const result = getCommandRoots ( 'echo `badCommand --danger`' ) ;
expect ( result ) . toEqual ( [ 'echo' , 'badCommand' ] ) ;
} ) ;
2025-11-04 11:11:29 -05:00
it ( 'should treat parameter expansions with prompt transformations as unsafe' , ( ) = > {
const roots = getCommandRoots (
'echo "${var1=aa\\140 env| ls -l\\140}${var1@P}"' ,
) ;
expect ( roots ) . toEqual ( [ ] ) ;
} ) ;
it ( 'should not return roots for prompt transformation expansions' , ( ) = > {
const roots = getCommandRoots ( 'echo ${foo@P}' ) ;
expect ( roots ) . toEqual ( [ ] ) ;
} ) ;
2026-01-02 11:36:59 -08:00
it ( 'should include nested command substitutions in redirected statements' , ( ) = > {
const result = getCommandRoots ( 'echo $(cat secret) > output.txt' ) ;
expect ( result ) . toEqual ( [ 'echo' , 'cat' ] ) ;
} ) ;
it ( 'should handle parser initialization failures gracefully' , async ( ) = > {
// Reset modules to clear singleton state
vi . resetModules ( ) ;
// Mock fileUtils to fail Wasm loading
vi . doMock ( './fileUtils.js' , ( ) = > ( {
loadWasmBinary : vi.fn ( ) . mockRejectedValue ( new Error ( 'Wasm load failed' ) ) ,
} ) ) ;
// Re-import shell-utils with mocked dependencies
const shellUtils = await import ( './shell-utils.js' ) ;
// Should catch the error and not throw
await expect ( shellUtils . initializeShellParsers ( ) ) . resolves . not . toThrow ( ) ;
// Fallback: splitting commands depends on parser, so if parser fails, it returns empty
const roots = shellUtils . getCommandRoots ( 'ls -la' ) ;
expect ( roots ) . toEqual ( [ ] ) ;
} ) ;
} ) ;
describe ( 'hasRedirection' , ( ) = > {
it ( 'should detect output redirection' , ( ) = > {
expect ( hasRedirection ( 'echo hello > world' ) ) . toBe ( true ) ;
} ) ;
it ( 'should detect input redirection' , ( ) = > {
expect ( hasRedirection ( 'cat < input' ) ) . toBe ( true ) ;
} ) ;
it ( 'should detect append redirection' , ( ) = > {
expect ( hasRedirection ( 'echo hello >> world' ) ) . toBe ( true ) ;
} ) ;
it ( 'should detect heredoc' , ( ) = > {
expect ( hasRedirection ( 'cat <<EOF\nhello\nEOF' ) ) . toBe ( true ) ;
} ) ;
it ( 'should detect herestring' , ( ) = > {
expect ( hasRedirection ( 'cat <<< "hello"' ) ) . toBe ( true ) ;
} ) ;
it ( 'should return false for simple commands' , ( ) = > {
expect ( hasRedirection ( 'ls -la' ) ) . toBe ( false ) ;
} ) ;
it ( 'should return false for pipes (pipes are not redirections in this context)' , ( ) = > {
// Note: pipes are often handled separately by splitCommands, but checking here confirms they don't trigger "redirection" flag if we don't want them to.
// However, the current implementation checks for 'redirected_statement' nodes.
// A pipe is a 'pipeline' node.
expect ( hasRedirection ( 'echo hello | cat' ) ) . toBe ( false ) ;
} ) ;
2025-10-16 17:25:30 -07:00
} ) ;
describeWindowsOnly ( 'PowerShell integration' , ( ) = > {
const originalComSpec = process . env [ 'ComSpec' ] ;
beforeEach ( ( ) = > {
mockPlatform . mockReturnValue ( 'win32' ) ;
const systemRoot = process . env [ 'SystemRoot' ] || 'C:\\\\Windows' ;
process . env [ 'ComSpec' ] =
` ${ systemRoot } \\ \\ System32 \\ \\ WindowsPowerShell \\ \\ v1.0 \\ \\ powershell.exe ` ;
} ) ;
afterEach ( ( ) = > {
if ( originalComSpec === undefined ) {
delete process . env [ 'ComSpec' ] ;
} else {
process . env [ 'ComSpec' ] = originalComSpec ;
}
} ) ;
it ( 'should return command roots using PowerShell AST output' , ( ) = > {
const roots = getCommandRoots ( 'Get-ChildItem | Select-Object Name' ) ;
expect ( roots . length ) . toBeGreaterThan ( 0 ) ;
expect ( roots ) . toContain ( 'Get-ChildItem' ) ;
} ) ;
2025-07-25 12:25:32 -07:00
} ) ;
describe ( 'stripShellWrapper' , ( ) = > {
2025-07-27 02:00:26 -04:00
it ( 'should strip sh -c with quotes' , ( ) = > {
expect ( stripShellWrapper ( 'sh -c "ls -l"' ) ) . toEqual ( 'ls -l' ) ;
2025-07-25 12:25:32 -07:00
} ) ;
2025-07-27 02:00:26 -04:00
it ( 'should strip bash -c with extra whitespace' , ( ) = > {
expect ( stripShellWrapper ( ' bash -c "ls -l" ' ) ) . toEqual ( 'ls -l' ) ;
2025-07-25 12:25:32 -07:00
} ) ;
2025-07-27 02:00:26 -04:00
it ( 'should strip zsh -c without quotes' , ( ) = > {
expect ( stripShellWrapper ( 'zsh -c ls -l' ) ) . toEqual ( 'ls -l' ) ;
2025-07-25 12:25:32 -07:00
} ) ;
2025-07-27 02:00:26 -04:00
it ( 'should strip cmd.exe /c' , ( ) = > {
expect ( stripShellWrapper ( 'cmd.exe /c "dir"' ) ) . toEqual ( 'dir' ) ;
2025-07-25 12:25:32 -07:00
} ) ;
2025-10-16 17:25:30 -07:00
it ( 'should strip powershell.exe -Command with optional -NoProfile' , ( ) = > {
expect (
stripShellWrapper ( 'powershell.exe -NoProfile -Command "Get-ChildItem"' ) ,
) . toEqual ( 'Get-ChildItem' ) ;
expect (
stripShellWrapper ( 'powershell.exe -Command "Get-ChildItem"' ) ,
) . toEqual ( 'Get-ChildItem' ) ;
} ) ;
it ( 'should strip pwsh -Command wrapper' , ( ) = > {
expect (
stripShellWrapper ( 'pwsh -NoProfile -Command "Get-ChildItem"' ) ,
) . toEqual ( 'Get-ChildItem' ) ;
} ) ;
2025-07-27 02:00:26 -04:00
it ( 'should not strip anything if no wrapper is present' , ( ) = > {
expect ( stripShellWrapper ( 'ls -l' ) ) . toEqual ( 'ls -l' ) ;
2025-07-25 12:25:32 -07:00
} ) ;
} ) ;
2025-08-17 00:02:54 -04:00
describe ( 'escapeShellArg' , ( ) = > {
describe ( 'POSIX (bash)' , ( ) = > {
it ( 'should use shell-quote for escaping' , ( ) = > {
mockQuote . mockReturnValueOnce ( "'escaped value'" ) ;
const result = escapeShellArg ( 'raw value' , 'bash' ) ;
expect ( mockQuote ) . toHaveBeenCalledWith ( [ 'raw value' ] ) ;
expect ( result ) . toBe ( "'escaped value'" ) ;
} ) ;
it ( 'should handle empty strings' , ( ) = > {
const result = escapeShellArg ( '' , 'bash' ) ;
expect ( result ) . toBe ( '' ) ;
expect ( mockQuote ) . not . toHaveBeenCalled ( ) ;
} ) ;
} ) ;
describe ( 'Windows' , ( ) = > {
describe ( 'when shell is cmd.exe' , ( ) = > {
it ( 'should wrap simple arguments in double quotes' , ( ) = > {
const result = escapeShellArg ( 'search term' , 'cmd' ) ;
expect ( result ) . toBe ( '"search term"' ) ;
} ) ;
it ( 'should escape internal double quotes by doubling them' , ( ) = > {
const result = escapeShellArg ( 'He said "Hello"' , 'cmd' ) ;
expect ( result ) . toBe ( '"He said ""Hello"""' ) ;
} ) ;
it ( 'should handle empty strings' , ( ) = > {
const result = escapeShellArg ( '' , 'cmd' ) ;
expect ( result ) . toBe ( '' ) ;
} ) ;
} ) ;
describe ( 'when shell is PowerShell' , ( ) = > {
it ( 'should wrap simple arguments in single quotes' , ( ) = > {
const result = escapeShellArg ( 'search term' , 'powershell' ) ;
expect ( result ) . toBe ( "'search term'" ) ;
} ) ;
it ( 'should escape internal single quotes by doubling them' , ( ) = > {
const result = escapeShellArg ( "It's a test" , 'powershell' ) ;
expect ( result ) . toBe ( "'It''s a test'" ) ;
} ) ;
it ( 'should handle double quotes without escaping them' , ( ) = > {
const result = escapeShellArg ( 'He said "Hello"' , 'powershell' ) ;
expect ( result ) . toBe ( '\'He said "Hello"\'' ) ;
} ) ;
it ( 'should handle empty strings' , ( ) = > {
const result = escapeShellArg ( '' , 'powershell' ) ;
expect ( result ) . toBe ( '' ) ;
} ) ;
} ) ;
} ) ;
} ) ;
describe ( 'getShellConfiguration' , ( ) = > {
const originalEnv = { . . . process . env } ;
afterEach ( ( ) = > {
process . env = originalEnv ;
} ) ;
it ( 'should return bash configuration on Linux' , ( ) = > {
mockPlatform . mockReturnValue ( 'linux' ) ;
const config = getShellConfiguration ( ) ;
expect ( config . executable ) . toBe ( 'bash' ) ;
expect ( config . argsPrefix ) . toEqual ( [ '-c' ] ) ;
expect ( config . shell ) . toBe ( 'bash' ) ;
} ) ;
it ( 'should return bash configuration on macOS (darwin)' , ( ) = > {
mockPlatform . mockReturnValue ( 'darwin' ) ;
const config = getShellConfiguration ( ) ;
expect ( config . executable ) . toBe ( 'bash' ) ;
expect ( config . argsPrefix ) . toEqual ( [ '-c' ] ) ;
expect ( config . shell ) . toBe ( 'bash' ) ;
} ) ;
describe ( 'on Windows' , ( ) = > {
beforeEach ( ( ) = > {
mockPlatform . mockReturnValue ( 'win32' ) ;
} ) ;
2025-10-16 17:25:30 -07:00
it ( 'should return PowerShell configuration by default' , ( ) = > {
2025-08-17 12:43:21 -04:00
delete process . env [ 'ComSpec' ] ;
2025-08-17 00:02:54 -04:00
const config = getShellConfiguration ( ) ;
2025-10-16 17:25:30 -07:00
expect ( config . executable ) . toBe ( 'powershell.exe' ) ;
expect ( config . argsPrefix ) . toEqual ( [ '-NoProfile' , '-Command' ] ) ;
expect ( config . shell ) . toBe ( 'powershell' ) ;
2025-08-17 00:02:54 -04:00
} ) ;
2025-10-16 17:25:30 -07:00
it ( 'should ignore ComSpec when pointing to cmd.exe' , ( ) = > {
2025-08-17 00:02:54 -04:00
const cmdPath = 'C:\\WINDOWS\\system32\\cmd.exe' ;
2025-08-17 12:43:21 -04:00
process . env [ 'ComSpec' ] = cmdPath ;
2025-08-17 00:02:54 -04:00
const config = getShellConfiguration ( ) ;
2025-10-16 17:25:30 -07:00
expect ( config . executable ) . toBe ( 'powershell.exe' ) ;
expect ( config . argsPrefix ) . toEqual ( [ '-NoProfile' , '-Command' ] ) ;
expect ( config . shell ) . toBe ( 'powershell' ) ;
2025-08-17 00:02:54 -04:00
} ) ;
it ( 'should return PowerShell configuration if ComSpec points to powershell.exe' , ( ) = > {
const psPath =
'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' ;
2025-08-17 12:43:21 -04:00
process . env [ 'ComSpec' ] = psPath ;
2025-08-17 00:02:54 -04:00
const config = getShellConfiguration ( ) ;
expect ( config . executable ) . toBe ( psPath ) ;
expect ( config . argsPrefix ) . toEqual ( [ '-NoProfile' , '-Command' ] ) ;
expect ( config . shell ) . toBe ( 'powershell' ) ;
} ) ;
it ( 'should return PowerShell configuration if ComSpec points to pwsh.exe' , ( ) = > {
const pwshPath = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe' ;
2025-08-17 12:43:21 -04:00
process . env [ 'ComSpec' ] = pwshPath ;
2025-08-17 00:02:54 -04:00
const config = getShellConfiguration ( ) ;
expect ( config . executable ) . toBe ( pwshPath ) ;
expect ( config . argsPrefix ) . toEqual ( [ '-NoProfile' , '-Command' ] ) ;
expect ( config . shell ) . toBe ( 'powershell' ) ;
} ) ;
it ( 'should be case-insensitive when checking ComSpec' , ( ) = > {
2025-08-17 12:43:21 -04:00
process . env [ 'ComSpec' ] = 'C:\\Path\\To\\POWERSHELL.EXE' ;
2025-08-17 00:02:54 -04:00
const config = getShellConfiguration ( ) ;
expect ( config . executable ) . toBe ( 'C:\\Path\\To\\POWERSHELL.EXE' ) ;
expect ( config . argsPrefix ) . toEqual ( [ '-NoProfile' , '-Command' ] ) ;
expect ( config . shell ) . toBe ( 'powershell' ) ;
} ) ;
} ) ;
} ) ;
2026-01-02 11:36:59 -08:00
describe ( 'hasRedirection (PowerShell via mock)' , ( ) = > {
beforeEach ( ( ) = > {
mockPlatform . mockReturnValue ( 'win32' ) ;
process . env [ 'ComSpec' ] = 'powershell.exe' ;
} ) ;
const mockPowerShellResult = (
commands : Array < { name : string ; text : string } > ,
hasRedirection : boolean ,
) = > {
mockSpawnSync . mockReturnValue ( {
stdout : Buffer.from (
JSON . stringify ( {
success : true ,
commands ,
hasRedirection ,
} ) ,
) ,
stderr : Buffer.from ( '' ) ,
status : 0 ,
error : undefined ,
} ) ;
} ;
it ( 'should return true when PowerShell parser detects redirection' , ( ) = > {
mockPowerShellResult ( [ { name : 'echo' , text : 'echo hello' } ] , true ) ;
expect ( hasRedirection ( 'echo hello > file.txt' ) ) . toBe ( true ) ;
} ) ;
it ( 'should return false when PowerShell parser does not detect redirection' , ( ) = > {
mockPowerShellResult ( [ { name : 'echo' , text : 'echo hello' } ] , false ) ;
expect ( hasRedirection ( 'echo hello' ) ) . toBe ( false ) ;
} ) ;
it ( 'should return false when quoted redirection chars are used but not actual redirection' , ( ) = > {
mockPowerShellResult (
[ { name : 'echo' , text : 'echo "-> arrow"' } ] ,
false , // Parser says NO redirection
) ;
expect ( hasRedirection ( 'echo "-> arrow"' ) ) . toBe ( false ) ;
} ) ;
it ( 'should fallback to regex if parsing fails (simulating safety)' , ( ) = > {
mockSpawnSync . mockReturnValue ( {
stdout : Buffer.from ( 'invalid json' ) ,
status : 0 ,
} ) ;
// Fallback regex sees '>' in arrow
expect ( hasRedirection ( 'echo "-> arrow"' ) ) . toBe ( true ) ;
} ) ;
} ) ;