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,
|
|
|
|
|
} 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
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
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(' '),
|
|
|
|
|
);
|
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([]);
|
|
|
|
|
});
|
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');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|