Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions src/option-parsers-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
parseEnvironmentVariables,
joinShellArgs,
} from './option-parsers';
import { escapeShellArg } from './parsers/shell-utils';

describe('environment variable parsing', () => {
it('should parse KEY=VALUE format correctly', () => {
Expand Down Expand Up @@ -73,28 +72,28 @@ describe('environment variable parsing', () => {
});
});

describe('shell argument escaping', () => {
describe('shell argument joining', () => {
it('should not escape simple arguments', () => {
expect(escapeShellArg('curl')).toBe('curl');
expect(escapeShellArg('https://api.github.com')).toBe('https://api.github.com');
expect(escapeShellArg('/usr/bin/node')).toBe('/usr/bin/node');
expect(escapeShellArg('--log-level=debug')).toBe('--log-level=debug');
expect(joinShellArgs(['curl'])).toBe('curl');
expect(joinShellArgs(['https://api.github.com'])).toBe('https://api.github.com');
expect(joinShellArgs(['/usr/bin/node'])).toBe('/usr/bin/node');
expect(joinShellArgs(['--log-level=debug'])).toBe('--log-level=debug');
});

it('should escape arguments with spaces', () => {
expect(escapeShellArg('hello world')).toBe("'hello world'");
expect(escapeShellArg('Authorization: Bearer token')).toBe("'Authorization: Bearer token'");
expect(joinShellArgs(['hello world'])).toBe("'hello world'");
expect(joinShellArgs(['Authorization: Bearer token'])).toBe("'Authorization: Bearer token'");
});

it('should escape arguments with special characters', () => {
expect(escapeShellArg('test$var')).toBe("'test$var'");
expect(escapeShellArg('test`cmd`')).toBe("'test`cmd`'");
expect(escapeShellArg('test;echo')).toBe("'test;echo'");
expect(joinShellArgs(['test$var'])).toBe("'test$var'");
expect(joinShellArgs(['test`cmd`'])).toBe("'test`cmd`'");
expect(joinShellArgs(['test;echo'])).toBe("'test;echo'");
});

it('should escape single quotes in arguments', () => {
expect(escapeShellArg("it's")).toBe("'it'\\''s'");
expect(escapeShellArg("don't")).toBe("'don'\\''t'");
expect(joinShellArgs(["it's"])).toBe("'it'\\''s'");
expect(joinShellArgs(["don't"])).toBe("'don'\\''t'");
});

it('should join multiple arguments with proper escaping', () => {
Expand Down
56 changes: 29 additions & 27 deletions src/parsers/shell-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,93 +1,95 @@
import { escapeShellArg, joinShellArgs } from './shell-utils';
import { joinShellArgs } from './shell-utils';

describe('escapeShellArg', () => {
const escapeSingleArg = (arg: string): string => joinShellArgs([arg]);

describe('single-argument shell escaping via joinShellArgs', () => {
describe('safe characters (no quoting needed)', () => {
it('should return simple alphanumeric strings as-is', () => {
expect(escapeShellArg('hello')).toBe('hello');
expect(escapeShellArg('abc123')).toBe('abc123');
expect(escapeSingleArg('hello')).toBe('hello');
expect(escapeSingleArg('abc123')).toBe('abc123');
});

it('should return strings with allowed safe chars as-is', () => {
expect(escapeShellArg('file.txt')).toBe('file.txt');
expect(escapeShellArg('/usr/bin/node')).toBe('/usr/bin/node');
expect(escapeShellArg('key=value')).toBe('key=value');
expect(escapeShellArg('host:port')).toBe('host:port');
expect(escapeShellArg('my-file')).toBe('my-file');
expect(escapeShellArg('my_var')).toBe('my_var');
expect(escapeSingleArg('file.txt')).toBe('file.txt');
expect(escapeSingleArg('/usr/bin/node')).toBe('/usr/bin/node');
expect(escapeSingleArg('key=value')).toBe('key=value');
expect(escapeSingleArg('host:port')).toBe('host:port');
expect(escapeSingleArg('my-file')).toBe('my-file');
expect(escapeSingleArg('my_var')).toBe('my_var');
});
});

describe('strings requiring quoting', () => {
it('should wrap strings with spaces in single quotes', () => {
expect(escapeShellArg('hello world')).toBe("'hello world'");
expect(escapeSingleArg('hello world')).toBe("'hello world'");
});

it('should wrap strings with dollar signs in single quotes', () => {
expect(escapeShellArg('$HOME')).toBe("'$HOME'");
expect(escapeSingleArg('$HOME')).toBe("'$HOME'");
});

it('should wrap strings with backticks in single quotes', () => {
expect(escapeShellArg('`cmd`')).toBe("'`cmd`'");
expect(escapeSingleArg('`cmd`')).toBe("'`cmd`'");
});

it('should wrap strings with semicolons in single quotes (command injection prevention)', () => {
expect(escapeShellArg('; rm -rf /')).toBe("'; rm -rf /'");
expect(escapeSingleArg('; rm -rf /')).toBe("'; rm -rf /'");
});

it('should wrap strings with ampersands in single quotes', () => {
expect(escapeShellArg('a && b')).toBe("'a && b'");
expect(escapeSingleArg('a && b')).toBe("'a && b'");
});

it('should wrap strings with pipes in single quotes', () => {
expect(escapeShellArg('a | b')).toBe("'a | b'");
expect(escapeSingleArg('a | b')).toBe("'a | b'");
});

it('should wrap strings with redirect operators in single quotes', () => {
expect(escapeShellArg('a > b')).toBe("'a > b'");
expect(escapeShellArg('a < b')).toBe("'a < b'");
expect(escapeSingleArg('a > b')).toBe("'a > b'");
expect(escapeSingleArg('a < b')).toBe("'a < b'");
});

it('should wrap strings with exclamation marks in single quotes', () => {
expect(escapeShellArg('hello!')).toBe("'hello!'");
expect(escapeSingleArg('hello!')).toBe("'hello!'");
});

it('should wrap strings with newlines in single quotes', () => {
expect(escapeShellArg('line1\nline2')).toBe("'line1\nline2'");
expect(escapeSingleArg('line1\nline2')).toBe("'line1\nline2'");
});
});

describe('strings with single quotes (injection prevention)', () => {
it('should escape single quotes using the standard shell pattern', () => {
expect(escapeShellArg("it's")).toBe("'it'\\''s'");
expect(escapeSingleArg("it's")).toBe("'it'\\''s'");
});

it('should handle strings that are only a single quote', () => {
expect(escapeShellArg("'")).toBe("''\\'''");
expect(escapeSingleArg("'")).toBe("''\\'''");
});

it('should handle strings with multiple single quotes', () => {
expect(escapeShellArg("a'b'c")).toBe("'a'\\''b'\\''c'");
expect(escapeSingleArg("a'b'c")).toBe("'a'\\''b'\\''c'");
});

it('should handle injection attempt with single quote and shell metacharacters', () => {
const injection = "'; rm -rf /; echo '";
const escaped = escapeShellArg(injection);
const escaped = escapeSingleArg(injection);
// Should be safely quoted so no shell injection can occur
// The two surrounding ' chars and the embedded '\'' escapes neutralize all metacharacters
expect(escaped).toBe("''\\''; rm -rf /; echo '\\'''" );
expect(escaped).toBe("''\\''; rm -rf /; echo '\\'''");
});
});

describe('empty and edge cases', () => {
it('should wrap empty string in single quotes', () => {
// Empty string does not match the safe-character regex because it requires at least one character,
// so it should be quoted.
const result = escapeShellArg('');
const result = escapeSingleArg('');
expect(result).toBe("''");
});

it('should handle strings with only special characters', () => {
expect(escapeShellArg('***')).toBe("'***'");
expect(escapeSingleArg('***')).toBe("'***'");
});
});
});
Expand Down
2 changes: 1 addition & 1 deletion src/parsers/shell-utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Escapes a shell argument by wrapping it in single quotes and escaping any single quotes within it
*/
export function escapeShellArg(arg: string): string {
function escapeShellArg(arg: string): string {
// If the argument doesn't contain special characters, return as-is
// Character class includes: letters, digits, underscore, dash, dot (literal), slash, equals, colon
if (/^[a-zA-Z0-9_\-./=:]+$/.test(arg)) {
Expand Down
Loading