Skip to content

fix: CDK app fails to launch if paths contain spaces #645

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 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
266 changes: 266 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { ToolkitError } from '../../toolkit/toolkit-error';

type ShellSyntax = 'posix' | 'windows';

/**
* Class to help with parsing and formatting command-lines
*
* What syntax we recognizing is an attribute of the `parse` and `toString()` operations,
* NOT of the command line itself. Defaults to the current platform.
*/
export class CommandLine {
/**
* Parse a command line into components.
*
* On Windows, emulates the behavior of `CommandLineToArgvW`. On Linux, emulates the behavior of a POSIX shell.
*
* (See: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-commandlinetoargvw)
*/
public static parse(cmdLine: string, syntax?: ShellSyntax) {
const argv = isWindows(syntax) ? parseCommandLineWindows(cmdLine) : parseCommandLinePosix(cmdLine);
return new CommandLine(argv);
}

constructor(public readonly argv: string[]) {
}

/**
* Render the command line as a string, taking care only to quote whitespace and quotes
*
* Any other special characters are left in exactly as-is.
*/
public toStringGrouped(syntax?: ShellSyntax) {
if (isWindows(syntax)) {
return formatCommandLineWindows(this.argv, /^\\S+$/);
} else {
return formatCommandLinePosix(this.argv, /^\\S+$/);
}
}

/**
* Render the command line as a string, escaping characters that would be interpreted by the shell
*
* The command will be a command invocation with literal parameters, nothing else.
*/
public toStringInert(syntax?: ShellSyntax) {
if (isWindows(syntax)) {
return formatCommandLineWindows(this.argv, /^[a-zA-Z0-9._\-+=/:]+$/);
} else {
return formatCommandLinePosix(this.argv, /^[a-zA-Z0-9._\-+=/:]+$/);
}
}

public toString() {
return this.toStringGrouped();
}
}

/**
* Parse command line on Windows
*
* @see https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments?view=msvc-170
*/
function parseCommandLineWindows(commandLine: string): string[] {
const ret: string[] = [];
let current = '';
let quoted = false;
let backSlashcount = 0;

for (let i = 0; i < commandLine.length; i++) {
const c = commandLine[i];

if (c === '\\') {
backSlashcount += 1;
continue;
}

// We also allow quoting " by doubling it up.
if (c === '"' && i + 1 < commandLine.length && commandLine[i + 1] === '"') {
current += '"';
i += 1;
continue;
}

// Only type of quote is ", and backslashes only behave specially before a "
if (c === '"') {
if (backSlashcount % 2 === 0) {
current += '\\'.repeat(backSlashcount / 2);
quoted = !quoted;
} else {
current += '\\'.repeat(Math.floor(backSlashcount / 2)) + '"';
}
backSlashcount = 0;

continue;
}

if (backSlashcount > 0) {
current += '\\'.repeat(backSlashcount);
backSlashcount = 0;
}

if (quoted) {
current += c;
continue;
}

if (isWhitespace(c)) {
if (current) {
ret.push(current);
}
current = '';
continue;
}

current += c;
}

if (current) {
ret.push(current);
}

return ret;
}

function isWhitespace(char: string): boolean {
return char === ' ' || char === '\t';
}

function isWindows(x?: ShellSyntax) {
return x ? x === 'windows' : process.platform === 'win32';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can make this assumption. What about Windows users using bash?

Copy link
Contributor Author

@rix0rrr rix0rrr Jun 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know, but subprocess.spawn(..., { shell: true }) will always use cmd.exe on Windows.

If you want bash you'll need to explicitly do subprocess.spawn("bash -c \"...\"", { ... }).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well actually it does respect %COMSPEC%, so let us also respect that.


function parseCommandLinePosix(commandLine: string): string[] {
const result: string[] = [];
let current = '';
let inDoubleQuote = false;
let inSingleQuote = false;
let escapeNext = false;

for (let i = 0; i < commandLine.length; i++) {
const char = commandLine[i];

// Handle escape character
if (escapeNext) {
// In double quotes, only certain characters are escaped
if (inDoubleQuote && !'\\"$`'.includes(char)) {
current += '\\';
}
current += char;
escapeNext = false;
continue;
}

if (char === '\\' && !inSingleQuote) {
escapeNext = true;
continue;
}

// Handle quotes
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
continue;
}

if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
continue;
}

// Handle whitespace
if (!inDoubleQuote && !inSingleQuote && /\s/.test(char)) {
if (current) {
result.push(current);
current = '';
}
continue;
}

current += char;
}

// Add the last argument if there is one
if (current) {
result.push(current);
}

// Check for unclosed quotes
if (inDoubleQuote || inSingleQuote) {
throw new ToolkitError('Unclosed quotes in command line');
}

// Check for trailing backslash
if (escapeNext) {
throw new ToolkitError('Trailing backslash in command line');
}

return result;
}

/**
* Format a command line in a sensible way
*/
function formatCommandLinePosix(argv: string[], componentIsSafe: RegExp): string {
return argv.map(arg => {
// Empty string needs quotes
if (arg === '') {
return '\'\'';
}

// If argument contains no problematic characters, return it as-is
if (componentIsSafe.test(arg)) {
return arg;
}

const escaped = Array.from(arg).map(char => char === '\'' || char === '\\' ? `\\${char}` : char);
return `'${escaped}'`;
}).join(' ');
}

/**
* Format a command line in a sensible way
*/
function formatCommandLineWindows(argv: string[], componentIsSafe: RegExp): string {
return argv.map(arg => {
// Empty string needs quotes
if (arg === '') {
return '""';
}

// If argument contains no problematic characters, return it as-is
if (componentIsSafe.test(arg)) {
return arg;
}

let escaped = '"';
let backslashCount = 0;

for (let i = 0; i < arg.length; i++) {
const char = arg[i];

if (char === '\\') {
// Count consecutive backslashes
backslashCount++;
} else if (char === '"') {
// Double the backslashes before a quote and escape the quote
escaped += '\\'.repeat(backslashCount * 2 + 1) + '"';
backslashCount = 0;
} else {
// Add accumulated backslashes if any
if (backslashCount > 0) {
escaped += '\\'.repeat(backslashCount);
backslashCount = 0;
}
escaped += char;
}
}

// Handle trailing backslashes before the closing quote
if (backslashCount > 0) {
escaped += '\\'.repeat(backslashCount * 2);
}

escaped += '"';
return escaped;
}).join(' ');
}
70 changes: 33 additions & 37 deletions packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as cxapi from '@aws-cdk/cx-api';
import * as fs from 'fs-extra';
import type { SdkProvider } from '../aws-auth/private';
import type { Settings } from '../settings';
import { CommandLine } from './command-line';

export type Env = { [key: string]: string | undefined };
export type Context = { [key: string]: unknown };
Expand Down Expand Up @@ -105,60 +106,55 @@ export function spaceAvailableForContext(env: Env, limit: number) {
}

/**
* Guess the executable from the command-line argument
* Guess the interpreter from the command-line argument, if the argument is a single file name (no arguments)
*
* Only do this if the file is NOT marked as executable. If it is,
* we'll defer to the shebang inside the file itself.
* - On Windows: it's hard to verify if registry associations have or have not
* been set up for this file type (i.e., ShellExec'ing the file will work or not),
* so we'll assume the worst and take control.
*
* If we're on Windows, we ALWAYS take the handler, since it's hard to
* verify if registry associations have or have not been set up for this
* file type, so we'll assume the worst and take control.
* - On POSIX: if the file is NOT marked as executable, guess the interpreter. If it is executable,
* executing the file will work and the correct interpreter should be in the file's shebang.
*/
export async function guessExecutable(app: string, debugFn: (msg: string) => Promise<void>) {
const commandLine = appToArray(app);
if (commandLine.length === 1) {
let fstat;

try {
fstat = await fs.stat(commandLine[0]);
} catch {
await debugFn(`Not a file: '${commandLine[0]}'. Using '${commandLine}' as command-line`);
return commandLine;
}

// eslint-disable-next-line no-bitwise
const isExecutable = (fstat.mode & fs.constants.X_OK) !== 0;
const isWindows = process.platform === 'win32';

const handler = EXTENSION_MAP.get(path.extname(commandLine[0]));
if (handler && (!isExecutable || isWindows)) {
return handler(commandLine[0]);
}
export async function guessExecutable(app: string, debugFn: (msg: string) => Promise<void>): Promise<CommandLine> {
const commandLine = CommandLine.parse(app);

if (commandLine.argv.length !== 1) {
return commandLine;
}

let fstat;

try {
fstat = await fs.stat(commandLine.argv[0]);
} catch {
await debugFn(`Not a file: '${commandLine.argv[0]}'. Using '${commandLine}' as command-line`);
return commandLine;
}

// eslint-disable-next-line no-bitwise
const isExecutable = (fstat.mode & fs.constants.X_OK) !== 0;
const isWindows = process.platform === 'win32';

const handler = EXTENSION_MAP.get(path.extname(commandLine.argv[0]));
if (handler && (!isExecutable || isWindows)) {
return new CommandLine(handler(commandLine.argv[0]));
}

return commandLine;
}

/**
* Mapping of extensions to command-line generators
*/
const EXTENSION_MAP = new Map<string, CommandGenerator>([
['.js', executeNode],
['.js', executeCurrentNode],
]);

type CommandGenerator = (file: string) => string[];

/**
* Execute the given file with the same 'node' process as is running the current process
*/
function executeNode(scriptFile: string): string[] {
function executeCurrentNode(scriptFile: string): string[] {
return [process.execPath, scriptFile];
}

/**
* Make sure the 'app' is an array
*
* If it's a string, split on spaces as a trivial way of tokenizing the command line.
*/
function appToArray(app: any) {
return typeof app === 'string' ? app.split(' ') : app;
}
Loading
Loading