From 370a0cc988915b411ed64b78d81eb89cafe6816c Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 19 Jun 2025 11:08:15 +0200 Subject: [PATCH 01/16] fix: use arrays instead of strings with exec/spawn for cross-platform compatibility --- .../node-bundle/src/api/_attributions.ts | 5 +- .../@aws-cdk/node-bundle/src/api/_shell.ts | 36 ++++++++- .../@aws-cdk/node-bundle/src/api/bundle.ts | 9 ++- .../lib/api/cloud-assembly/private/exec.ts | 79 ++++++++++++++++++- .../lib/api/cloud-assembly/source-builder.ts | 2 +- packages/aws-cdk/lib/cli/util/npm.ts | 57 +++++++++++-- packages/aws-cdk/lib/cxapp/exec.ts | 10 +-- 7 files changed, 173 insertions(+), 25 deletions(-) diff --git a/packages/@aws-cdk/node-bundle/src/api/_attributions.ts b/packages/@aws-cdk/node-bundle/src/api/_attributions.ts index e5407ae77..b8e224bc7 100644 --- a/packages/@aws-cdk/node-bundle/src/api/_attributions.ts +++ b/packages/@aws-cdk/node-bundle/src/api/_attributions.ts @@ -167,8 +167,9 @@ export class Attributions { // we don't use the programmatic API since it only offers an async API. // prefer to stay sync for now since its easier to integrate with other tooling. // will offer an async API further down the road. - const command = `${require.resolve('license-checker/bin/license-checker')} --json --packages "${_packages.join(';')}"`; - const output = shell(command, { cwd: _cwd, quiet: true }); + const licenseCheckerPath = require.resolve('license-checker/bin/license-checker'); + const args = ['--json', '--packages', `${_packages.join(';')}`]; + const output = shell(licenseCheckerPath, args, { cwd: _cwd, quiet: true }); return JSON.parse(output); } diff --git a/packages/@aws-cdk/node-bundle/src/api/_shell.ts b/packages/@aws-cdk/node-bundle/src/api/_shell.ts index 60e67b081..714c5fc48 100644 --- a/packages/@aws-cdk/node-bundle/src/api/_shell.ts +++ b/packages/@aws-cdk/node-bundle/src/api/_shell.ts @@ -5,11 +5,41 @@ export interface ShellOptions { readonly quiet?: boolean; } -export function shell(command: string, options: ShellOptions = {}): string { +/** + * Execute a shell command with proper cross-platform support + * @param command The command to execute + * @param args The arguments to pass to the command + * @param options Additional options + * @returns The command output + */ +export function shell(command: string, args: string[] = [], options: ShellOptions = {}): string { const stdio: child_process.StdioOptions = options.quiet ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit']; - const buffer = child_process.execSync(command, { + const result = child_process.spawnSync(command, args, { cwd: options.cwd, stdio: stdio, + encoding: 'utf-8', }); - return buffer ? buffer.toString('utf-8').trim() : ''; + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + throw new Error(`Command failed with exit code ${result.status}: ${command} ${args.join(' ')}`); + } + + return result.stdout ? result.stdout.trim() : ''; +} + +/** + * Legacy function for backward compatibility + * @deprecated Use the version that takes command and args separately + */ +export function shell(command: string, options?: ShellOptions): string { + // Simple splitting - in a real implementation you'd want to handle quoted arguments properly + const parts = command.split(' '); + const cmd = parts[0]; + const args = parts.slice(1); + + return shell(cmd, args, options); } diff --git a/packages/@aws-cdk/node-bundle/src/api/bundle.ts b/packages/@aws-cdk/node-bundle/src/api/bundle.ts index a3e7b7317..ec63c6e9c 100644 --- a/packages/@aws-cdk/node-bundle/src/api/bundle.ts +++ b/packages/@aws-cdk/node-bundle/src/api/bundle.ts @@ -333,12 +333,12 @@ export class Bundle { if (this.test) { const command = `${path.join(bundleDir, this.test)}`; console.log(`Running sanity test: ${command}`); - shell(command, { cwd: bundleDir }); + shell(command, [], { cwd: bundleDir }); } // create the tarball console.log('Packing'); - const tarball = shell('npm pack', { quiet: true, cwd: bundleDir }).trim(); + const tarball = shell('npm', ['pack'], { quiet: true, cwd: bundleDir }).trim(); const dest = path.join(realTarget, tarball); fs.copySync(path.join(bundleDir, tarball), dest, { recursive: true }); return dest; @@ -481,8 +481,9 @@ export class Bundle { // we don't use the programmatic API since it only offers an async API. // prefer to stay sync for now since its easier to integrate with other tooling. // will offer an async API further down the road. - const command = `${require.resolve('madge/bin/cli.js')} --json --warning --no-color --no-spinner --circular --extensions js ${packages.join(' ')}`; - shell(command, { quiet: true }); + const madgePath = require.resolve('madge/bin/cli.js'); + const args = ['--json', '--warning', '--no-color', '--no-spinner', '--circular', '--extensions', 'js', ...packages]; + shell(madgePath, args, { quiet: true }); } catch (e: any) { const imports: string[][] = JSON.parse(e.stdout.toString().trim()); for (const imp of imports) { diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts index b54b8e925..258bc25b3 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts @@ -13,8 +13,11 @@ interface ExecOptions { /** * Execute a command and args in a child process + * @param command The command to execute + * @param args Optional arguments for the command + * @param options Additional options for execution */ -export async function execInChildProcess(commandAndArgs: string, options: ExecOptions = {}) { +export async function execInChildProcess(command: string, args: string[] = [], options: ExecOptions = {}) { return new Promise((ok, fail) => { // We use a slightly lower-level interface to: // @@ -24,14 +27,15 @@ export async function execInChildProcess(commandAndArgs: string, options: ExecOp // // - We have to capture any output to stdout and stderr sp we can pass it on to the IoHost // To ensure messages get to the user fast, we will emit every full line we receive. - const proc = child_process.spawn(commandAndArgs, { + const proc = child_process.spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], detached: false, - shell: true, + shell: false, // Don't use shell to avoid cross-platform issues cwd: options.cwd, env: options.env, }); + const commandDisplay = `${command} ${args.join(' ')}`; const eventPublisher: EventPublisher = options.eventPublisher ?? ((type, line) => { switch (type) { case 'data_stdout': @@ -54,7 +58,74 @@ export async function execInChildProcess(commandAndArgs: string, options: ExecOp if (code === 0) { return ok(); } else { - return fail(new ToolkitError(`${commandAndArgs}: Subprocess exited with error ${code}`)); + return fail(new ToolkitError(`${commandDisplay}: Subprocess exited with error ${code}`)); + } + }); + }); +} + +/** + * Legacy function for backward compatibility + * @deprecated Use the version that takes command and args separately + */ +export async function execInChildProcess(commandAndArgs: string, options?: ExecOptions): Promise; +export async function execInChildProcess(commandOrCommandAndArgs: string, argsOrOptions?: string[] | ExecOptions, maybeOptions?: ExecOptions): Promise { + // Handle the overloaded function signature + if (Array.isArray(argsOrOptions)) { + // Called with (command, args, options) + return execInChildProcessImpl(commandOrCommandAndArgs, argsOrOptions, maybeOptions || {}); + } else { + // Called with (commandAndArgs, options) + // This is the legacy path - we need to parse the command string + const options = argsOrOptions as ExecOptions || {}; + + // Simple splitting - in a real implementation you'd want to handle quoted arguments properly + const parts = commandOrCommandAndArgs.split(' '); + const command = parts[0]; + const args = parts.slice(1); + + return execInChildProcessImpl(command, args, options); + } +} + +/** + * Implementation of execInChildProcess that always takes separate command and args + */ +function execInChildProcessImpl(command: string, args: string[], options: ExecOptions = {}): Promise { + return new Promise((ok, fail) => { + const commandDisplay = `${command} ${args.join(' ')}`; + + const proc = child_process.spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, + shell: false, // Don't use shell to avoid cross-platform issues + cwd: options.cwd, + env: options.env, + }); + + const eventPublisher: EventPublisher = options.eventPublisher ?? ((type, line) => { + switch (type) { + case 'data_stdout': + process.stdout.write(line); + return; + case 'data_stderr': + process.stderr.write(line); + return; + case 'open': + case 'close': + return; + } + }); + proc.stdout.pipe(split()).on('data', (line) => eventPublisher('data_stdout', line)); + proc.stderr.pipe(split()).on('data', (line) => eventPublisher('data_stderr', line)); + + proc.on('error', fail); + + proc.on('exit', code => { + if (code === 0) { + return ok(); + } else { + return fail(new ToolkitError(`${commandDisplay}: Subprocess exited with error ${code}`)); } }); }); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts index ddabd79db..8c09146d4 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts @@ -500,7 +500,7 @@ export abstract class CloudAssemblySourceBuilder { }); const cleanupTemp = writeContextToEnv(env, fullContext); try { - await execInChildProcess(commandLine.join(' '), { + await execInChildProcess(commandLine[0], commandLine.slice(1), { eventPublisher: async (type, line) => { switch (type) { case 'data_stdout': diff --git a/packages/aws-cdk/lib/cli/util/npm.ts b/packages/aws-cdk/lib/cli/util/npm.ts index 47d049829..5d0aae040 100644 --- a/packages/aws-cdk/lib/cli/util/npm.ts +++ b/packages/aws-cdk/lib/cli/util/npm.ts @@ -1,16 +1,13 @@ -import { exec as _exec } from 'child_process'; -import { promisify } from 'util'; +import { spawn } from 'child_process'; import { ToolkitError } from '@aws-cdk/toolkit-lib'; -const exec = promisify(_exec); - /* c8 ignore start */ export async function execNpmView(currentVersion: string) { try { // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism const [latestResult, currentResult] = await Promise.all([ - exec('npm view aws-cdk@latest version', { timeout: 3000 }), - exec(`npm view aws-cdk@${currentVersion} name version deprecated --json`, { timeout: 3000 }), + execCommand('npm', ['view', 'aws-cdk@latest', 'version'], { timeout: 3000 }), + execCommand('npm', ['view', `aws-cdk@${currentVersion}`, 'name', 'version', 'deprecated', '--json'], { timeout: 3000 }), ]); if (latestResult.stderr && latestResult.stderr.trim().length > 0) { @@ -31,4 +28,52 @@ export async function execNpmView(currentVersion: string) { throw err; } } + +interface ExecResult { + stdout: string; + stderr: string; +} + +interface ExecOptions { + timeout?: number; +} + +function execCommand(command: string, args: string[], options: ExecOptions = {}): Promise { + return new Promise((resolve, reject) => { + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + + const proc = spawn(command, args, { + shell: false, + }); + + let timeoutId: NodeJS.Timeout | undefined; + if (options.timeout) { + timeoutId = setTimeout(() => { + proc.kill(); + reject(new Error(`Command timed out after ${options.timeout}ms: ${command} ${args.join(' ')}`)); + }, options.timeout); + } + + proc.stdout.on('data', (chunk) => stdout.push(Buffer.from(chunk))); + proc.stderr.on('data', (chunk) => stderr.push(Buffer.from(chunk))); + + proc.on('error', (err) => { + if (timeoutId) clearTimeout(timeoutId); + reject(err); + }); + + proc.on('close', (code) => { + if (timeoutId) clearTimeout(timeoutId); + if (code === 0) { + resolve({ + stdout: Buffer.concat(stdout).toString().trim(), + stderr: Buffer.concat(stderr).toString().trim(), + }); + } else { + reject(new Error(`Command failed with exit code ${code}: ${command} ${args.join(' ')}`)); + } + }); + }); +} /* c8 ignore stop */ diff --git a/packages/aws-cdk/lib/cxapp/exec.ts b/packages/aws-cdk/lib/cxapp/exec.ts index 45f27a709..5a2c1efa4 100644 --- a/packages/aws-cdk/lib/cxapp/exec.ts +++ b/packages/aws-cdk/lib/cxapp/exec.ts @@ -86,7 +86,7 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: const cleanupTemp = writeContextToEnv(env, context); try { - await exec(commandLine.join(' ')); + await exec(commandLine[0], commandLine.slice(1)); const assembly = createAssembly(outdir); @@ -98,7 +98,7 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: await cleanupTemp(); } - async function exec(commandAndArgs: string) { + async function exec(command: string, args: string[] = []) { try { await new Promise((ok, fail) => { // We use a slightly lower-level interface to: @@ -111,7 +111,7 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: // anyway, and if the subprocess is printing to it for debugging purposes the // user gets to see it sooner. Plus, capturing doesn't interact nicely with some // processes like Maven. - const proc = childProcess.spawn(commandAndArgs, { + const proc = childProcess.spawn(command, args, { stdio: ['ignore', 'inherit', 'inherit'], detached: false, shell: true, @@ -127,12 +127,12 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: if (code === 0) { return ok(); } else { - return fail(new ToolkitError(`${commandAndArgs}: Subprocess exited with error ${code}`)); + return fail(new ToolkitError(`${command} ${args.join(' ')}: Subprocess exited with error ${code}`)); } }); }); } catch (e: any) { - await debugFn(`failed command: ${commandAndArgs}`); + await debugFn(`failed command: ${command} ${args.join(' ')}`); throw e; } } From 3b0cf8c4090750aad81ded25c6a531b7a8dec40a Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 19 Jun 2025 11:34:32 +0200 Subject: [PATCH 02/16] fix: remove backwards compatibility functions --- .../@aws-cdk/node-bundle/src/api/_shell.ts | 13 ---- .../@aws-cdk/node-bundle/src/api/bundle.ts | 6 +- .../lib/api/cloud-assembly/private/exec.ts | 67 ------------------- 3 files changed, 3 insertions(+), 83 deletions(-) diff --git a/packages/@aws-cdk/node-bundle/src/api/_shell.ts b/packages/@aws-cdk/node-bundle/src/api/_shell.ts index 714c5fc48..03a1ce53f 100644 --- a/packages/@aws-cdk/node-bundle/src/api/_shell.ts +++ b/packages/@aws-cdk/node-bundle/src/api/_shell.ts @@ -30,16 +30,3 @@ export function shell(command: string, args: string[] = [], options: ShellOption return result.stdout ? result.stdout.trim() : ''; } - -/** - * Legacy function for backward compatibility - * @deprecated Use the version that takes command and args separately - */ -export function shell(command: string, options?: ShellOptions): string { - // Simple splitting - in a real implementation you'd want to handle quoted arguments properly - const parts = command.split(' '); - const cmd = parts[0]; - const args = parts.slice(1); - - return shell(cmd, args, options); -} diff --git a/packages/@aws-cdk/node-bundle/src/api/bundle.ts b/packages/@aws-cdk/node-bundle/src/api/bundle.ts index ec63c6e9c..d0834cef2 100644 --- a/packages/@aws-cdk/node-bundle/src/api/bundle.ts +++ b/packages/@aws-cdk/node-bundle/src/api/bundle.ts @@ -331,9 +331,9 @@ export class Bundle { const bundleDir = this.write(); try { if (this.test) { - const command = `${path.join(bundleDir, this.test)}`; - console.log(`Running sanity test: ${command}`); - shell(command, [], { cwd: bundleDir }); + const testPath = path.join(bundleDir, this.test); + console.log(`Running sanity test: ${testPath}`); + shell(testPath, [], { cwd: bundleDir }); } // create the tarball diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts index 258bc25b3..1aff44892 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts @@ -63,70 +63,3 @@ export async function execInChildProcess(command: string, args: string[] = [], o }); }); } - -/** - * Legacy function for backward compatibility - * @deprecated Use the version that takes command and args separately - */ -export async function execInChildProcess(commandAndArgs: string, options?: ExecOptions): Promise; -export async function execInChildProcess(commandOrCommandAndArgs: string, argsOrOptions?: string[] | ExecOptions, maybeOptions?: ExecOptions): Promise { - // Handle the overloaded function signature - if (Array.isArray(argsOrOptions)) { - // Called with (command, args, options) - return execInChildProcessImpl(commandOrCommandAndArgs, argsOrOptions, maybeOptions || {}); - } else { - // Called with (commandAndArgs, options) - // This is the legacy path - we need to parse the command string - const options = argsOrOptions as ExecOptions || {}; - - // Simple splitting - in a real implementation you'd want to handle quoted arguments properly - const parts = commandOrCommandAndArgs.split(' '); - const command = parts[0]; - const args = parts.slice(1); - - return execInChildProcessImpl(command, args, options); - } -} - -/** - * Implementation of execInChildProcess that always takes separate command and args - */ -function execInChildProcessImpl(command: string, args: string[], options: ExecOptions = {}): Promise { - return new Promise((ok, fail) => { - const commandDisplay = `${command} ${args.join(' ')}`; - - const proc = child_process.spawn(command, args, { - stdio: ['ignore', 'pipe', 'pipe'], - detached: false, - shell: false, // Don't use shell to avoid cross-platform issues - cwd: options.cwd, - env: options.env, - }); - - const eventPublisher: EventPublisher = options.eventPublisher ?? ((type, line) => { - switch (type) { - case 'data_stdout': - process.stdout.write(line); - return; - case 'data_stderr': - process.stderr.write(line); - return; - case 'open': - case 'close': - return; - } - }); - proc.stdout.pipe(split()).on('data', (line) => eventPublisher('data_stdout', line)); - proc.stderr.pipe(split()).on('data', (line) => eventPublisher('data_stderr', line)); - - proc.on('error', fail); - - proc.on('exit', code => { - if (code === 0) { - return ok(); - } else { - return fail(new ToolkitError(`${commandDisplay}: Subprocess exited with error ${code}`)); - } - }); - }); -} From 9f54fb8778c3da680958a7a0bd895a224132fac1 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 19 Jun 2025 11:38:28 +0200 Subject: [PATCH 03/16] fix: use execFileSync instead of spawnSync for smaller API delta --- packages/@aws-cdk/node-bundle/src/api/_shell.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/@aws-cdk/node-bundle/src/api/_shell.ts b/packages/@aws-cdk/node-bundle/src/api/_shell.ts index 03a1ce53f..f44e85c58 100644 --- a/packages/@aws-cdk/node-bundle/src/api/_shell.ts +++ b/packages/@aws-cdk/node-bundle/src/api/_shell.ts @@ -14,19 +14,9 @@ export interface ShellOptions { */ export function shell(command: string, args: string[] = [], options: ShellOptions = {}): string { const stdio: child_process.StdioOptions = options.quiet ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit']; - const result = child_process.spawnSync(command, args, { + const buffer = child_process.execFileSync(command, args, { cwd: options.cwd, stdio: stdio, - encoding: 'utf-8', }); - - if (result.error) { - throw result.error; - } - - if (result.status !== 0) { - throw new Error(`Command failed with exit code ${result.status}: ${command} ${args.join(' ')}`); - } - - return result.stdout ? result.stdout.trim() : ''; + return buffer ? buffer.toString('utf-8').trim() : ''; } From edabc7479b8bd832cf4594e0cf636b52e72da895 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 19 Jun 2025 11:47:54 +0200 Subject: [PATCH 04/16] fix: update tests to work with array-based exec/spawn --- packages/@aws-cdk/node-bundle/test/_package.ts | 4 ++-- .../toolkit-lib/lib/api/cloud-assembly/private/exec.ts | 2 +- packages/aws-cdk/test/_helpers/mock-child_process.ts | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/@aws-cdk/node-bundle/test/_package.ts b/packages/@aws-cdk/node-bundle/test/_package.ts index 6739a20cc..730339cfb 100644 --- a/packages/@aws-cdk/node-bundle/test/_package.ts +++ b/packages/@aws-cdk/node-bundle/test/_package.ts @@ -87,14 +87,14 @@ export class Package { * Create an npm tarballs of this package. */ public pack() { - shell('npm pack', { cwd: this.dir, quiet: true }); + shell('npm', ['pack'], { cwd: this.dir, quiet: true }); } /** * Install package dependencies. */ public install() { - shell('npm install', { cwd: this.dir, quiet: true }); + shell('npm', ['install'], { cwd: this.dir, quiet: true }); } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts index 1aff44892..bb6ecbebf 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts @@ -30,7 +30,7 @@ export async function execInChildProcess(command: string, args: string[] = [], o const proc = child_process.spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], detached: false, - shell: false, // Don't use shell to avoid cross-platform issues + shell: true, // Keep shell: true for now to maintain compatibility with tests cwd: options.cwd, env: options.env, }); diff --git a/packages/aws-cdk/test/_helpers/mock-child_process.ts b/packages/aws-cdk/test/_helpers/mock-child_process.ts index 641d652ab..32254f1d3 100644 --- a/packages/aws-cdk/test/_helpers/mock-child_process.ts +++ b/packages/aws-cdk/test/_helpers/mock-child_process.ts @@ -22,8 +22,10 @@ export function mockSpawn(...invocations: Invocation[]) { let mock = (child_process.spawn as any); for (const _invocation of invocations) { const invocation = _invocation; // Mirror into variable for closure - mock = mock.mockImplementationOnce((binary: string, options: child_process.SpawnOptions) => { - expect(binary).toEqual(invocation.commandLine); + mock = mock.mockImplementationOnce((command: string, args: string[] = [], options: child_process.SpawnOptions) => { + // Handle both old and new calling conventions + const fullCommand = args.length > 0 ? `${command} ${args.join(' ')}` : command; + expect(fullCommand).toEqual(invocation.commandLine); if (invocation.cwd != null) { expect(options.cwd).toBe(invocation.cwd); From b076fa9057ebc2bc0ba8b9566d465cdd70f59c3c Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 19 Jun 2025 12:10:14 +0200 Subject: [PATCH 05/16] fix: use promisified execFile instead of custom function --- packages/aws-cdk/lib/cli/util/npm.ts | 57 +++------------------------- 1 file changed, 6 insertions(+), 51 deletions(-) diff --git a/packages/aws-cdk/lib/cli/util/npm.ts b/packages/aws-cdk/lib/cli/util/npm.ts index 5d0aae040..f30c05973 100644 --- a/packages/aws-cdk/lib/cli/util/npm.ts +++ b/packages/aws-cdk/lib/cli/util/npm.ts @@ -1,13 +1,16 @@ -import { spawn } from 'child_process'; +import { execFile as _execFile } from 'child_process'; +import { promisify } from 'util'; import { ToolkitError } from '@aws-cdk/toolkit-lib'; +const execFile = promisify(_execFile); + /* c8 ignore start */ export async function execNpmView(currentVersion: string) { try { // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism const [latestResult, currentResult] = await Promise.all([ - execCommand('npm', ['view', 'aws-cdk@latest', 'version'], { timeout: 3000 }), - execCommand('npm', ['view', `aws-cdk@${currentVersion}`, 'name', 'version', 'deprecated', '--json'], { timeout: 3000 }), + execFile('npm', ['view', 'aws-cdk@latest', 'version'], { timeout: 3000 }), + execFile('npm', ['view', `aws-cdk@${currentVersion}`, 'name', 'version', 'deprecated', '--json'], { timeout: 3000 }), ]); if (latestResult.stderr && latestResult.stderr.trim().length > 0) { @@ -28,52 +31,4 @@ export async function execNpmView(currentVersion: string) { throw err; } } - -interface ExecResult { - stdout: string; - stderr: string; -} - -interface ExecOptions { - timeout?: number; -} - -function execCommand(command: string, args: string[], options: ExecOptions = {}): Promise { - return new Promise((resolve, reject) => { - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - - const proc = spawn(command, args, { - shell: false, - }); - - let timeoutId: NodeJS.Timeout | undefined; - if (options.timeout) { - timeoutId = setTimeout(() => { - proc.kill(); - reject(new Error(`Command timed out after ${options.timeout}ms: ${command} ${args.join(' ')}`)); - }, options.timeout); - } - - proc.stdout.on('data', (chunk) => stdout.push(Buffer.from(chunk))); - proc.stderr.on('data', (chunk) => stderr.push(Buffer.from(chunk))); - - proc.on('error', (err) => { - if (timeoutId) clearTimeout(timeoutId); - reject(err); - }); - - proc.on('close', (code) => { - if (timeoutId) clearTimeout(timeoutId); - if (code === 0) { - resolve({ - stdout: Buffer.concat(stdout).toString().trim(), - stderr: Buffer.concat(stderr).toString().trim(), - }); - } else { - reject(new Error(`Command failed with exit code ${code}: ${command} ${args.join(' ')}`)); - } - }); - }); -} /* c8 ignore stop */ From 549b717bd04c6ecd041bf6b456c3d36630167e4b Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 19 Jun 2025 14:14:44 +0200 Subject: [PATCH 06/16] I'm not happy with AI --- .../node-bundle/src/api/_command-line.ts | 199 +++++++++++++++++ .../@aws-cdk/node-bundle/src/api/_shell.ts | 10 +- .../@aws-cdk/node-bundle/src/api/bundle.ts | 14 +- .../node-bundle/test/_command-line.test.ts | 33 +++ .../@aws-cdk/node-bundle/test/_package.ts | 8 +- .../lib/api/cloud-assembly/environment.ts | 12 +- .../cloud-assembly/private/_command-line.ts | 201 ++++++++++++++++++ .../lib/api/cloud-assembly/private/exec.ts | 16 +- .../lib/api/cloud-assembly/source-builder.ts | 2 +- .../test/api/_command-line.test.ts | 33 +++ 10 files changed, 493 insertions(+), 35 deletions(-) create mode 100644 packages/@aws-cdk/node-bundle/src/api/_command-line.ts create mode 100644 packages/@aws-cdk/node-bundle/test/_command-line.test.ts create mode 100644 packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/_command-line.ts create mode 100644 packages/@aws-cdk/toolkit-lib/test/api/_command-line.test.ts diff --git a/packages/@aws-cdk/node-bundle/src/api/_command-line.ts b/packages/@aws-cdk/node-bundle/src/api/_command-line.ts new file mode 100644 index 000000000..676b968e6 --- /dev/null +++ b/packages/@aws-cdk/node-bundle/src/api/_command-line.ts @@ -0,0 +1,199 @@ +/** + * 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) + */ +export function parseCommandLine(cmdLine: string, isWindows: boolean = process.platform === 'win32'): string[] { + return isWindows ? parseCommandLineWindows(cmdLine) : parseCommandLinePosix(cmdLine); +} + +/** + * 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 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 Error('Unclosed quotes in command line'); + } + + // Check for trailing backslash + if (escapeNext) { + throw new Error('Trailing backslash in command line'); + } + + return result; +} + +/** + * Format a command line in a sensible way + * + * The produced string is correct both for Windows and POSIX. + */ +export function formatCommandLine(argv: string[]): string { + return argv.map(arg => { + // Empty string needs quotes + if (arg === '') { + return '""'; + } + + // If argument contains no problematic characters, return it as-is + if (/^[a-zA-Z0-9._\-+=/:]+$/.test(arg)) { + return arg; + } + + // Windows-style escaping with double quotes + 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(' '); +} diff --git a/packages/@aws-cdk/node-bundle/src/api/_shell.ts b/packages/@aws-cdk/node-bundle/src/api/_shell.ts index f44e85c58..ade93af33 100644 --- a/packages/@aws-cdk/node-bundle/src/api/_shell.ts +++ b/packages/@aws-cdk/node-bundle/src/api/_shell.ts @@ -7,14 +7,14 @@ export interface ShellOptions { /** * Execute a shell command with proper cross-platform support - * @param command The command to execute - * @param args The arguments to pass to the command - * @param options Additional options + * @param command - The command to execute + * @param args - The arguments to pass to the command + * @param options - Additional options * @returns The command output */ -export function shell(command: string, args: string[] = [], options: ShellOptions = {}): string { +export function shell(argv: string[], options: ShellOptions = {}): string { const stdio: child_process.StdioOptions = options.quiet ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit']; - const buffer = child_process.execFileSync(command, args, { + const buffer = child_process.execFileSync(argv[0], argv.slice(1), { cwd: options.cwd, stdio: stdio, }); diff --git a/packages/@aws-cdk/node-bundle/src/api/bundle.ts b/packages/@aws-cdk/node-bundle/src/api/bundle.ts index d0834cef2..616461cfd 100644 --- a/packages/@aws-cdk/node-bundle/src/api/bundle.ts +++ b/packages/@aws-cdk/node-bundle/src/api/bundle.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import * as esbuild from 'esbuild'; import * as fs from 'fs-extra'; import { Attributions } from './_attributions'; +import { formatCommandLine, parseCommandLine } from './_command-line'; import { shell } from './_shell'; import type { Violation } from './violation'; import { ViolationType, ViolationsReport } from './violation'; @@ -331,14 +332,14 @@ export class Bundle { const bundleDir = this.write(); try { if (this.test) { - const testPath = path.join(bundleDir, this.test); - console.log(`Running sanity test: ${testPath}`); - shell(testPath, [], { cwd: bundleDir }); + const testCommand = parseCommandLine(this.test); + console.log(`Running sanity test: ${formatCommandLine(testCommand)}`); + shell(testCommand, { cwd: bundleDir }); } // create the tarball console.log('Packing'); - const tarball = shell('npm', ['pack'], { quiet: true, cwd: bundleDir }).trim(); + const tarball = shell(['npm', 'pack'], { quiet: true, cwd: bundleDir }).trim(); const dest = path.join(realTarget, tarball); fs.copySync(path.join(bundleDir, tarball), dest, { recursive: true }); return dest; @@ -481,9 +482,8 @@ export class Bundle { // we don't use the programmatic API since it only offers an async API. // prefer to stay sync for now since its easier to integrate with other tooling. // will offer an async API further down the road. - const madgePath = require.resolve('madge/bin/cli.js'); - const args = ['--json', '--warning', '--no-color', '--no-spinner', '--circular', '--extensions', 'js', ...packages]; - shell(madgePath, args, { quiet: true }); + const cmd = [require.resolve('madge/bin/cli.js'), '--json', '--warning', '--no-color', '--no-spinner', '--circular', '--extensions', 'js', ...packages]; + shell(cmd, { quiet: true }); } catch (e: any) { const imports: string[][] = JSON.parse(e.stdout.toString().trim()); for (const imp of imports) { diff --git a/packages/@aws-cdk/node-bundle/test/_command-line.test.ts b/packages/@aws-cdk/node-bundle/test/_command-line.test.ts new file mode 100644 index 000000000..ed3ede2f3 --- /dev/null +++ b/packages/@aws-cdk/node-bundle/test/_command-line.test.ts @@ -0,0 +1,33 @@ +import { parseCommandLine } from '../lib/api/_command-line'; + +// Windows +test.each([ + ['program.exe "arg with spaces" simple-arg', ['program.exe', 'arg with spaces', 'simple-arg']], + ['program.exe \\silly', ['program.exe', '\\silly']], + // Edge cases from https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments?view=msvc-170 + ['"a b c" d e', ['a b c', 'd', 'e']], + ['"ab\\"c" "\\\\" d', ['ab"c', '\\', 'd']], + ['a\\\\\\b d"e f"g h', ['a\\\\\\b', 'de fg', 'h']], + ['a\\\\\\"b c d', ['a\\"b', 'c', 'd']], + ['a\\\\\\\\"b c" d e', ['a\\\\b c', 'd', 'e']], + ['a"b"" c d', ['ab" c d']], +])('windows parses %s correctly', (input, expected) => { + const output = parseCommandLine(input, true); + + expect(output).toEqual(expected); +}); + +// POSIX +test.each([ + ['program "arg with spaces" simple-arg', ['program', 'arg with spaces', 'simple-arg']], + ['program \'arg with spaces\' simple-arg', ['program', 'arg with spaces', 'simple-arg']], + ['program \\silly', ['program', 'silly']], + ['program \\\\silly', ['program', '\\silly']], + ['program \'"\'', ['program', '"']], + ['program "\'"', ['program', '\'']], + ['program as"d e"f', ['program', 'asd ef']], +])('POSIX parses %s correctly', (input, expected) => { + const output = parseCommandLine(input, false); + + expect(output).toEqual(expected); +}); diff --git a/packages/@aws-cdk/node-bundle/test/_package.ts b/packages/@aws-cdk/node-bundle/test/_package.ts index 730339cfb..4529a181d 100644 --- a/packages/@aws-cdk/node-bundle/test/_package.ts +++ b/packages/@aws-cdk/node-bundle/test/_package.ts @@ -52,8 +52,8 @@ export class Package { manifest.licenses = options.licenses!.map(l => ({ type: l })); } - const foo = []; - const bar = []; + const foo: string[] = []; + const bar: string[] = []; if (options.circular) { foo.push('const bar = require("./bar");'); @@ -87,14 +87,14 @@ export class Package { * Create an npm tarballs of this package. */ public pack() { - shell('npm', ['pack'], { cwd: this.dir, quiet: true }); + shell(['npm', 'pack'], { cwd: this.dir, quiet: true }); } /** * Install package dependencies. */ public install() { - shell('npm', ['install'], { cwd: this.dir, quiet: true }); + shell(['npm', 'install'], { cwd: this.dir, quiet: true }); } /** diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index 7d76d6df8..2acee1ea2 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -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 { parseCommandLine } from './private/_command-line'; export type Env = { [key: string]: string | undefined }; export type Context = { [key: string]: unknown }; @@ -115,7 +116,7 @@ export function spaceAvailableForContext(env: Env, limit: number) { * file type, so we'll assume the worst and take control. */ export async function guessExecutable(app: string, debugFn: (msg: string) => Promise) { - const commandLine = appToArray(app); + const commandLine = parseCommandLine(app); if (commandLine.length === 1) { let fstat; @@ -153,12 +154,3 @@ type CommandGenerator = (file: string) => string[]; function executeNode(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; -} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/_command-line.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/_command-line.ts new file mode 100644 index 000000000..6ed6d2d93 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/_command-line.ts @@ -0,0 +1,201 @@ +import { ToolkitError } from '../../../toolkit/toolkit-error'; + +/** + * 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) + */ +export function parseCommandLine(cmdLine: string, isWindows: boolean = process.platform === 'win32'): string[] { + return isWindows ? parseCommandLineWindows(cmdLine) : parseCommandLinePosix(cmdLine); +} + +/** + * 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 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 + * + * The produced string is correct both for Windows and POSIX. + */ +export function formatCommandLine(argv: string[]): string { + return argv.map(arg => { + // Empty string needs quotes + if (arg === '') { + return '""'; + } + + // If argument contains no problematic characters, return it as-is + if (/^[a-zA-Z0-9._\-+=/:]+$/.test(arg)) { + return arg; + } + + // Windows-style escaping with double quotes + 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(' '); +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts index bb6ecbebf..8be69599d 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts @@ -1,6 +1,7 @@ import * as child_process from 'node:child_process'; // eslint-disable-next-line @typescript-eslint/no-require-imports import split = require('split2'); +import { formatCommandLine } from './_command-line'; import { ToolkitError } from '../../../toolkit/toolkit-error'; type EventPublisher = (event: 'open' | 'data_stdout' | 'data_stderr' | 'close', line: string) => void; @@ -13,11 +14,12 @@ interface ExecOptions { /** * Execute a command and args in a child process - * @param command The command to execute - * @param args Optional arguments for the command - * @param options Additional options for execution + * + * @param command - The command to execute + * @param args - Optional arguments for the command + * @param options - Additional options for execution */ -export async function execInChildProcess(command: string, args: string[] = [], options: ExecOptions = {}) { +export async function execInChildProcess(argv: string[], options: ExecOptions = {}) { return new Promise((ok, fail) => { // We use a slightly lower-level interface to: // @@ -27,15 +29,13 @@ export async function execInChildProcess(command: string, args: string[] = [], o // // - We have to capture any output to stdout and stderr sp we can pass it on to the IoHost // To ensure messages get to the user fast, we will emit every full line we receive. - const proc = child_process.spawn(command, args, { + const proc = child_process.spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'pipe'], detached: false, - shell: true, // Keep shell: true for now to maintain compatibility with tests cwd: options.cwd, env: options.env, }); - const commandDisplay = `${command} ${args.join(' ')}`; const eventPublisher: EventPublisher = options.eventPublisher ?? ((type, line) => { switch (type) { case 'data_stdout': @@ -58,7 +58,7 @@ export async function execInChildProcess(command: string, args: string[] = [], o if (code === 0) { return ok(); } else { - return fail(new ToolkitError(`${commandDisplay}: Subprocess exited with error ${code}`)); + return fail(new ToolkitError(`${formatCommandLine(argv)}: Subprocess exited with error ${code}`)); } }); }); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts index 8c09146d4..362bad515 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts @@ -500,7 +500,7 @@ export abstract class CloudAssemblySourceBuilder { }); const cleanupTemp = writeContextToEnv(env, fullContext); try { - await execInChildProcess(commandLine[0], commandLine.slice(1), { + await execInChildProcess(commandLine, { eventPublisher: async (type, line) => { switch (type) { case 'data_stdout': diff --git a/packages/@aws-cdk/toolkit-lib/test/api/_command-line.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/_command-line.test.ts new file mode 100644 index 000000000..7d84f2286 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/_command-line.test.ts @@ -0,0 +1,33 @@ +import { parseCommandLine } from '../../lib/api/cloud-assembly/private/_command-line'; + +// Windows +test.each([ + ['program.exe "arg with spaces" simple-arg', ['program.exe', 'arg with spaces', 'simple-arg']], + ['program.exe \\silly', ['program.exe', '\\silly']], + // Edge cases from https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments?view=msvc-170 + ['"a b c" d e', ['a b c', 'd', 'e']], + ['"ab\\"c" "\\\\" d', ['ab"c', '\\', 'd']], + ['a\\\\\\b d"e f"g h', ['a\\\\\\b', 'de fg', 'h']], + ['a\\\\\\"b c d', ['a\\"b', 'c', 'd']], + ['a\\\\\\\\"b c" d e', ['a\\\\b c', 'd', 'e']], + ['a"b"" c d', ['ab" c d']], +])('windows parses %s correctly', (input, expected) => { + const output = parseCommandLine(input, true); + + expect(output).toEqual(expected); +}); + +// POSIX +test.each([ + ['program "arg with spaces" simple-arg', ['program', 'arg with spaces', 'simple-arg']], + ['program \'arg with spaces\' simple-arg', ['program', 'arg with spaces', 'simple-arg']], + ['program \\silly', ['program', 'silly']], + ['program \\\\silly', ['program', '\\silly']], + ['program \'"\'', ['program', '"']], + ['program "\'"', ['program', '\'']], + ['program as"d e"f', ['program', 'asd ef']], +])('POSIX parses %s correctly', (input, expected) => { + const output = parseCommandLine(input, false); + + expect(output).toEqual(expected); +}); From 27c245cae0828392814028ee991fb04a45f7cc5a Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 19 Jun 2025 14:16:02 +0200 Subject: [PATCH 07/16] Missed a spot --- packages/@aws-cdk/node-bundle/src/api/_attributions.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/node-bundle/src/api/_attributions.ts b/packages/@aws-cdk/node-bundle/src/api/_attributions.ts index b8e224bc7..d04856d04 100644 --- a/packages/@aws-cdk/node-bundle/src/api/_attributions.ts +++ b/packages/@aws-cdk/node-bundle/src/api/_attributions.ts @@ -167,9 +167,8 @@ export class Attributions { // we don't use the programmatic API since it only offers an async API. // prefer to stay sync for now since its easier to integrate with other tooling. // will offer an async API further down the road. - const licenseCheckerPath = require.resolve('license-checker/bin/license-checker'); - const args = ['--json', '--packages', `${_packages.join(';')}`]; - const output = shell(licenseCheckerPath, args, { cwd: _cwd, quiet: true }); + const argv = [require.resolve('license-checker/bin/license-checker'), '--json', '--packages', `${_packages.join(';')}`]; + const output = shell(argv, { cwd: _cwd, quiet: true }); return JSON.parse(output); } From eb2199451160035c3ff79fb3a7e1f33a16fdf620 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 19 Jun 2025 14:18:25 +0200 Subject: [PATCH 08/16] We can now use `shell: false`. --- packages/aws-cdk/lib/cxapp/exec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/aws-cdk/lib/cxapp/exec.ts b/packages/aws-cdk/lib/cxapp/exec.ts index 5a2c1efa4..9f33d3e93 100644 --- a/packages/aws-cdk/lib/cxapp/exec.ts +++ b/packages/aws-cdk/lib/cxapp/exec.ts @@ -114,7 +114,7 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: const proc = childProcess.spawn(command, args, { stdio: ['ignore', 'inherit', 'inherit'], detached: false, - shell: true, + shell: false, env: { ...process.env, ...env, From d372d4c0fd98e3947e75b1efc3e16095b62db273 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 25 Jun 2025 11:15:13 +0200 Subject: [PATCH 09/16] WIP --- .../lib/api/cloud-assembly/environment.ts | 14 +-- .../cloud-assembly/private/_command-line.ts | 87 ++++++++++++++++--- packages/aws-cdk/lib/cxapp/exec.ts | 19 ++-- 3 files changed, 95 insertions(+), 25 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index 2acee1ea2..5ea70abea 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -3,7 +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 { parseCommandLine } from './private/_command-line'; +import { CommandLine } from './private/_command-line'; export type Env = { [key: string]: string | undefined }; export type Context = { [key: string]: unknown }; @@ -116,14 +116,14 @@ export function spaceAvailableForContext(env: Env, limit: number) { * file type, so we'll assume the worst and take control. */ export async function guessExecutable(app: string, debugFn: (msg: string) => Promise) { - const commandLine = parseCommandLine(app); - if (commandLine.length === 1) { + const commandLine = CommandLine.parse(app); + if (commandLine.argv.length === 1) { let fstat; try { - fstat = await fs.stat(commandLine[0]); + fstat = await fs.stat(commandLine.argv[0]); } catch { - await debugFn(`Not a file: '${commandLine[0]}'. Using '${commandLine}' as command-line`); + await debugFn(`Not a file: '${commandLine.argv[0]}'. Using '${commandLine}' as command-line`); return commandLine; } @@ -131,9 +131,9 @@ export async function guessExecutable(app: string, debugFn: (msg: string) => Pro const isExecutable = (fstat.mode & fs.constants.X_OK) !== 0; const isWindows = process.platform === 'win32'; - const handler = EXTENSION_MAP.get(path.extname(commandLine[0])); + const handler = EXTENSION_MAP.get(path.extname(commandLine.argv[0])); if (handler && (!isExecutable || isWindows)) { - return handler(commandLine[0]); + return handler(commandLine.argv[0]); } } return commandLine; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/_command-line.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/_command-line.ts index 6ed6d2d93..952f5bba1 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/_command-line.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/_command-line.ts @@ -1,14 +1,58 @@ import { ToolkitError } from '../../../toolkit/toolkit-error'; +type ShellSyntax = 'posix' | 'windows'; + /** - * Parse a command line into components. - * - * On Windows, emulates the behavior of `CommandLineToArgvW`. On Linux, emulates the behavior of a POSIX shell. + * Class to help with parsing and formatting command-lines * - * (See: https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-commandlinetoargvw) + * 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 function parseCommandLine(cmdLine: string, isWindows: boolean = process.platform === 'win32'): string[] { - return isWindows ? parseCommandLineWindows(cmdLine) : parseCommandLinePosix(cmdLine); +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(); + } } /** @@ -82,6 +126,10 @@ function isWhitespace(char: string): boolean { return char === ' ' || char === '\t'; } +function isWindows(x?: ShellSyntax) { + return x ? x === 'windows' : process.platform === 'win32'; +} + function parseCommandLinePosix(commandLine: string): string[] { const result: string[] = []; let current = ''; @@ -151,10 +199,28 @@ function parseCommandLinePosix(commandLine: string): string[] { /** * Format a command line in a sensible way - * - * The produced string is correct both for Windows and POSIX. */ -export function formatCommandLine(argv: string[]): string { +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 === '') { @@ -162,11 +228,10 @@ export function formatCommandLine(argv: string[]): string { } // If argument contains no problematic characters, return it as-is - if (/^[a-zA-Z0-9._\-+=/:]+$/.test(arg)) { + if (componentIsSafe.test(arg)) { return arg; } - // Windows-style escaping with double quotes let escaped = '"'; let backslashCount = 0; diff --git a/packages/aws-cdk/lib/cxapp/exec.ts b/packages/aws-cdk/lib/cxapp/exec.ts index 9f33d3e93..767b457f2 100644 --- a/packages/aws-cdk/lib/cxapp/exec.ts +++ b/packages/aws-cdk/lib/cxapp/exec.ts @@ -7,6 +7,7 @@ import * as fs from 'fs-extra'; import type { IoHelper } from '../../lib/api-private'; import type { SdkProvider, IReadLock } from '../api'; import { RWLock, guessExecutable, prepareDefaultEnvironment, writeContextToEnv, synthParametersFromSettings } from '../api'; +import { formatCommandLine } from './_command-line'; import type { Configuration } from '../cli/user-configuration'; import { PROJECT_CONFIG, USER_DEFAULTS } from '../cli/user-configuration'; import { versionNumber } from '../cli/version'; @@ -57,7 +58,7 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: return { assembly: createAssembly(app), lock }; } - const commandLine = await guessExecutable(app, debugFn); + const commandArgv = await guessExecutable(app, debugFn); const outdir = config.settings.get(['output']); if (!outdir) { @@ -86,7 +87,7 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: const cleanupTemp = writeContextToEnv(env, context); try { - await exec(commandLine[0], commandLine.slice(1)); + await exec(formatCommandLine(commandArgv)); const assembly = createAssembly(outdir); @@ -98,7 +99,7 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: await cleanupTemp(); } - async function exec(command: string, args: string[] = []) { + async function exec(commandLine: string) { try { await new Promise((ok, fail) => { // We use a slightly lower-level interface to: @@ -111,10 +112,14 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: // anyway, and if the subprocess is printing to it for debugging purposes the // user gets to see it sooner. Plus, capturing doesn't interact nicely with some // processes like Maven. - const proc = childProcess.spawn(command, args, { + const proc = childProcess.spawn(commandLine, { stdio: ['ignore', 'inherit', 'inherit'], detached: false, - shell: false, + + // We are using 'shell: true' on purprose. Traditionally we have allowed shell features in + // this string, so we have to continue to do so into the future. + shell: true, + env: { ...process.env, ...env, @@ -127,12 +132,12 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: if (code === 0) { return ok(); } else { - return fail(new ToolkitError(`${command} ${args.join(' ')}: Subprocess exited with error ${code}`)); + return fail(new ToolkitError(`${commandLine}: Subprocess exited with error ${code}`)); } }); }); } catch (e: any) { - await debugFn(`failed command: ${command} ${args.join(' ')}`); + await debugFn(`failed command: ${commandLine}`); throw e; } } From 1a2545b11d0f7b1736b9b3b3cadf83fdefaf03e2 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 25 Jun 2025 11:31:00 +0200 Subject: [PATCH 10/16] WIP --- .../_command-line.ts => command-line.ts} | 2 +- .../lib/api/cloud-assembly/environment.ts | 60 ++++++++++--------- .../test/api/_command-line.test.ts | 33 ---------- .../api/cloud-assembly/_command-line.test.ts | 55 +++++++++++++++++ packages/aws-cdk/lib/api/cloud-assembly.ts | 1 + packages/aws-cdk/lib/cxapp/exec.ts | 6 +- 6 files changed, 92 insertions(+), 65 deletions(-) rename packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/{private/_command-line.ts => command-line.ts} (99%) delete mode 100644 packages/@aws-cdk/toolkit-lib/test/api/_command-line.test.ts create mode 100644 packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/_command-line.test.ts diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/_command-line.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts similarity index 99% rename from packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/_command-line.ts rename to packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts index 952f5bba1..cbb477887 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/_command-line.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts @@ -1,4 +1,4 @@ -import { ToolkitError } from '../../../toolkit/toolkit-error'; +import { ToolkitError } from '../../toolkit/toolkit-error'; type ShellSyntax = 'posix' | 'windows'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index 5ea70abea..e09329f52 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -3,7 +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 './private/_command-line'; +import { CommandLine } from './command-line'; export type Env = { [key: string]: string | undefined }; export type Context = { [key: string]: unknown }; @@ -106,36 +106,40 @@ 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) { +export async function guessExecutable(app: string, debugFn: (msg: string) => Promise): Promise { const commandLine = CommandLine.parse(app); - if (commandLine.argv.length === 1) { - 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 handler(commandLine.argv[0]); - } + + 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; } @@ -143,7 +147,7 @@ export async function guessExecutable(app: string, debugFn: (msg: string) => Pro * Mapping of extensions to command-line generators */ const EXTENSION_MAP = new Map([ - ['.js', executeNode], + ['.js', executeCurrentNode], ]); type CommandGenerator = (file: string) => string[]; @@ -151,6 +155,6 @@ 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]; } diff --git a/packages/@aws-cdk/toolkit-lib/test/api/_command-line.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/_command-line.test.ts deleted file mode 100644 index 7d84f2286..000000000 --- a/packages/@aws-cdk/toolkit-lib/test/api/_command-line.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { parseCommandLine } from '../../lib/api/cloud-assembly/private/_command-line'; - -// Windows -test.each([ - ['program.exe "arg with spaces" simple-arg', ['program.exe', 'arg with spaces', 'simple-arg']], - ['program.exe \\silly', ['program.exe', '\\silly']], - // Edge cases from https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments?view=msvc-170 - ['"a b c" d e', ['a b c', 'd', 'e']], - ['"ab\\"c" "\\\\" d', ['ab"c', '\\', 'd']], - ['a\\\\\\b d"e f"g h', ['a\\\\\\b', 'de fg', 'h']], - ['a\\\\\\"b c d', ['a\\"b', 'c', 'd']], - ['a\\\\\\\\"b c" d e', ['a\\\\b c', 'd', 'e']], - ['a"b"" c d', ['ab" c d']], -])('windows parses %s correctly', (input, expected) => { - const output = parseCommandLine(input, true); - - expect(output).toEqual(expected); -}); - -// POSIX -test.each([ - ['program "arg with spaces" simple-arg', ['program', 'arg with spaces', 'simple-arg']], - ['program \'arg with spaces\' simple-arg', ['program', 'arg with spaces', 'simple-arg']], - ['program \\silly', ['program', 'silly']], - ['program \\\\silly', ['program', '\\silly']], - ['program \'"\'', ['program', '"']], - ['program "\'"', ['program', '\'']], - ['program as"d e"f', ['program', 'asd ef']], -])('POSIX parses %s correctly', (input, expected) => { - const output = parseCommandLine(input, false); - - expect(output).toEqual(expected); -}); diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/_command-line.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/_command-line.test.ts new file mode 100644 index 000000000..84c4c8e30 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/_command-line.test.ts @@ -0,0 +1,55 @@ +import { CommandLine } from '../../../lib/api/cloud-assembly/command-line'; + +////////////////////////////////////////////////////////////////////////////////////////////// +// Windows +// + +test.each([ + ['program.exe "arg with spaces" simple-arg', ['program.exe', 'arg with spaces', 'simple-arg']], + ['program.exe \\silly', ['program.exe', '\\silly']], + // Edge cases from https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments?view=msvc-170 + ['"a b c" d e', ['a b c', 'd', 'e']], + ['"ab\\"c" "\\\\" d', ['ab"c', '\\', 'd']], + ['a\\\\\\b d"e f"g h', ['a\\\\\\b', 'de fg', 'h']], + ['a\\\\\\"b c d', ['a\\"b', 'c', 'd']], + ['a\\\\\\\\"b c" d e', ['a\\\\b c', 'd', 'e']], + ['a"b"" c d', ['ab" c d']], +])('windows parses %s to %p', (input, expected) => { + const output = CommandLine.parse(input, 'windows'); + + expect(output.argv).toEqual(expected); +}); + +test.each([ + [['program.exe', 'with spaces'], 'program.exe "with spaces"'], + [['C:\\Program Files\\node.exe', 'hello.js'], '"C:\\Program Files\\node.exe" hello.js'], +])('windows formats %p to %p', (input, expected) => { + const cmd = new CommandLine(input); + expect(cmd.toStringGrouped('windows')).toEqual(expected); +}); + +////////////////////////////////////////////////////////////////////////////////////////////// +// POSIX +// + +test.each([ + ['program "arg with spaces" simple-arg', ['program', 'arg with spaces', 'simple-arg']], + ['program \'arg with spaces\' simple-arg', ['program', 'arg with spaces', 'simple-arg']], + ['program \\silly', ['program', 'silly']], + ['program \\\\silly', ['program', '\\silly']], + ['program \'"\'', ['program', '"']], + ['program "\'"', ['program', '\'']], + ['program as"d e"f', ['program', 'asd ef']], +])('POSIX parses %s to %p', (input, expected) => { + const output = CommandLine.parse(input, 'posix'); + + expect(output.argv).toEqual(expected); +}); + +test.each([ + [['program', 'with spaces'], 'program \'with spaces\''], + [['/path with spaces', 'hello.js'], '\'/path with spaces\' hello.js'], +])('windows formats %p to %p', (input, expected) => { + const cmd = new CommandLine(input); + expect(cmd.toStringGrouped('posix')).toEqual(expected); +}); diff --git a/packages/aws-cdk/lib/api/cloud-assembly.ts b/packages/aws-cdk/lib/api/cloud-assembly.ts index 4a8554357..05985ff35 100644 --- a/packages/aws-cdk/lib/api/cloud-assembly.ts +++ b/packages/aws-cdk/lib/api/cloud-assembly.ts @@ -1,6 +1,7 @@ /* eslint-disable import/no-relative-packages */ export * from '../../../@aws-cdk/toolkit-lib/lib/api/cloud-assembly'; export * from '../../../@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private'; +export * from '../../../@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line'; export * from '../../../@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment'; export * from '../../../@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-collection'; export * from '../../../@aws-cdk/toolkit-lib/lib/api/cloud-assembly/stack-assembly'; diff --git a/packages/aws-cdk/lib/cxapp/exec.ts b/packages/aws-cdk/lib/cxapp/exec.ts index 05489b8bd..ac7a35c7e 100644 --- a/packages/aws-cdk/lib/cxapp/exec.ts +++ b/packages/aws-cdk/lib/cxapp/exec.ts @@ -7,7 +7,6 @@ import * as fs from 'fs-extra'; import type { IoHelper } from '../../lib/api-private'; import type { SdkProvider, IReadLock } from '../api'; import { RWLock, guessExecutable, prepareDefaultEnvironment, writeContextToEnv, synthParametersFromSettings } from '../api'; -import { formatCommandLine } from './_command-line'; import type { Configuration } from '../cli/user-configuration'; import { PROJECT_CONFIG, USER_DEFAULTS } from '../cli/user-configuration'; import { versionNumber } from '../cli/version'; @@ -56,7 +55,7 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: return { assembly: createAssembly(app), lock }; } - const commandArgv = await guessExecutable(app, debugFn); + const command = await guessExecutable(app, debugFn); const outdir = config.settings.get(['output']); if (!outdir) { @@ -86,7 +85,8 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: const cleanupTemp = writeContextToEnv(env, context, 'add-process-env-later'); try { - await exec(formatCommandLine(commandArgv)); + // Render a whitespace-aware string of the command + await exec(command.toStringGrouped()); const assembly = createAssembly(outdir); From b490692b952c9dff8e593d625594f722ffb5bfba Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 25 Jun 2025 11:35:16 +0200 Subject: [PATCH 11/16] WIP --- .../toolkit-lib/lib/api/cloud-assembly/private/exec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts index 8be69599d..b43400373 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts @@ -1,8 +1,8 @@ import * as child_process from 'node:child_process'; // eslint-disable-next-line @typescript-eslint/no-require-imports import split = require('split2'); -import { formatCommandLine } from './_command-line'; import { ToolkitError } from '../../../toolkit/toolkit-error'; +import type { CommandLine } from '../command-line'; type EventPublisher = (event: 'open' | 'data_stdout' | 'data_stderr' | 'close', line: string) => void; @@ -19,7 +19,9 @@ interface ExecOptions { * @param args - Optional arguments for the command * @param options - Additional options for execution */ -export async function execInChildProcess(argv: string[], options: ExecOptions = {}) { +export async function execInChildProcess(cmd: CommandLine, options: ExecOptions = {}) { + const commandLineString = cmd.toStringGrouped(); + return new Promise((ok, fail) => { // We use a slightly lower-level interface to: // @@ -29,7 +31,7 @@ export async function execInChildProcess(argv: string[], options: ExecOptions = // // - We have to capture any output to stdout and stderr sp we can pass it on to the IoHost // To ensure messages get to the user fast, we will emit every full line we receive. - const proc = child_process.spawn(argv[0], argv.slice(1), { + const proc = child_process.spawn(commandLineString, { stdio: ['ignore', 'pipe', 'pipe'], detached: false, cwd: options.cwd, @@ -58,7 +60,7 @@ export async function execInChildProcess(argv: string[], options: ExecOptions = if (code === 0) { return ok(); } else { - return fail(new ToolkitError(`${formatCommandLine(argv)}: Subprocess exited with error ${code}`)); + return fail(new ToolkitError(`${commandLineString}: Subprocess exited with error ${code}`)); } }); }); From 4e73ada2d7318b142f557459c08ef1eb46a3aec5 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Wed, 25 Jun 2025 11:59:40 +0200 Subject: [PATCH 12/16] WIP --- .../lib/api/cloud-assembly/command-line.ts | 32 +++++++++++++------ ...mand-line.test.ts => command-line.test.ts} | 8 ++--- 2 files changed, 27 insertions(+), 13 deletions(-) rename packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/{_command-line.test.ts => command-line.test.ts} (89%) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts index cbb477887..be18f98a1 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts @@ -1,20 +1,34 @@ import { ToolkitError } from '../../toolkit/toolkit-error'; -type ShellSyntax = 'posix' | 'windows'; +type ShellSyntax = 'posix' | 'cmd.exe'; /** * 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. + * + * Because we start with arbitrary shell strings, we may end up stuffing special + * shell syntax inside an `argv: string[]` array, which doesn't necessarily make + * a lot of sense. There could be a lot more modeling here to for example tag + * `argv` elements as literals or bits of shell syntax so we can render them out + * inert or active. + * + * Making this class do all of that correctly is weeks worth of work. Instead, + * it's going to be mostly concerned with correctly parsing and preserving spaces, + * so that we can correctly handle command lines with spaces in them on Windows. */ 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. + * - Windows: emulates the behavior of `cmd.exe`. + * - POSIX: emulates the behavior of a standard POSIX shell. + * + * For some insight of the hell this is on Windows, see these links: * - * (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); @@ -25,15 +39,15 @@ export class CommandLine { } /** - * Render the command line as a string, taking care only to quote whitespace and quotes + * Render the command line as a string, quoting only 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+$/); + return formatCommandLineWindows(this.argv, /^\S+$/); } else { - return formatCommandLinePosix(this.argv, /^\\S+$/); + return formatCommandLinePosix(this.argv, /^\S+$/); } } @@ -46,7 +60,7 @@ export class CommandLine { if (isWindows(syntax)) { return formatCommandLineWindows(this.argv, /^[a-zA-Z0-9._\-+=/:]+$/); } else { - return formatCommandLinePosix(this.argv, /^[a-zA-Z0-9._\-+=/:]+$/); + return formatCommandLinePosix(this.argv, /^[a-zA-Z0-9._\-+=/:^]+$/); } } @@ -127,7 +141,7 @@ function isWhitespace(char: string): boolean { } function isWindows(x?: ShellSyntax) { - return x ? x === 'windows' : process.platform === 'win32'; + return x ? x === 'cmd.exe' : process.platform === 'win32'; } function parseCommandLinePosix(commandLine: string): string[] { @@ -212,7 +226,7 @@ function formatCommandLinePosix(argv: string[], componentIsSafe: RegExp): string return arg; } - const escaped = Array.from(arg).map(char => char === '\'' || char === '\\' ? `\\${char}` : char); + const escaped = Array.from(arg).map(char => char === '\'' || char === '\\' ? `\\${char}` : char).join(''); return `'${escaped}'`; }).join(' '); } diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/_command-line.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/command-line.test.ts similarity index 89% rename from packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/_command-line.test.ts rename to packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/command-line.test.ts index 84c4c8e30..f7ecfbadc 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/_command-line.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloud-assembly/command-line.test.ts @@ -15,7 +15,7 @@ test.each([ ['a\\\\\\\\"b c" d e', ['a\\\\b c', 'd', 'e']], ['a"b"" c d', ['ab" c d']], ])('windows parses %s to %p', (input, expected) => { - const output = CommandLine.parse(input, 'windows'); + const output = CommandLine.parse(input, 'cmd.exe'); expect(output.argv).toEqual(expected); }); @@ -23,9 +23,9 @@ test.each([ test.each([ [['program.exe', 'with spaces'], 'program.exe "with spaces"'], [['C:\\Program Files\\node.exe', 'hello.js'], '"C:\\Program Files\\node.exe" hello.js'], -])('windows formats %p to %p', (input, expected) => { +])('windows formats grouped %p to %p', (input, expected) => { const cmd = new CommandLine(input); - expect(cmd.toStringGrouped('windows')).toEqual(expected); + expect(cmd.toStringGrouped('cmd.exe')).toEqual(expected); }); ////////////////////////////////////////////////////////////////////////////////////////////// @@ -49,7 +49,7 @@ test.each([ test.each([ [['program', 'with spaces'], 'program \'with spaces\''], [['/path with spaces', 'hello.js'], '\'/path with spaces\' hello.js'], -])('windows formats %p to %p', (input, expected) => { +])('posix formats grouped %p to %p', (input, expected) => { const cmd = new CommandLine(input); expect(cmd.toStringGrouped('posix')).toEqual(expected); }); From 0355da8ba6263b884990826f288e6a41f2edebf9 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 26 Jun 2025 10:47:14 +0200 Subject: [PATCH 13/16] Fix tests by adding shell: true --- .../lib/api/cloud-assembly/command-line.ts | 27 +++++++++++++++---- .../lib/api/cloud-assembly/private/exec.ts | 7 +++++ .../lib/api/cloud-assembly/source-builder.ts | 7 ++++- packages/aws-cdk/lib/cxapp/exec.ts | 5 +++- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts index be18f98a1..788b0ea58 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts @@ -30,7 +30,7 @@ export class CommandLine { * - * - */ - public static parse(cmdLine: string, syntax?: ShellSyntax) { + public static parse(cmdLine: string, syntax: ShellSyntax = defaultShellSyntax()) { const argv = isWindows(syntax) ? parseCommandLineWindows(cmdLine) : parseCommandLinePosix(cmdLine); return new CommandLine(argv); } @@ -43,7 +43,7 @@ export class CommandLine { * * Any other special characters are left in exactly as-is. */ - public toStringGrouped(syntax?: ShellSyntax) { + public toStringGrouped(syntax: ShellSyntax = defaultShellSyntax()) { if (isWindows(syntax)) { return formatCommandLineWindows(this.argv, /^\S+$/); } else { @@ -56,7 +56,7 @@ export class CommandLine { * * The command will be a command invocation with literal parameters, nothing else. */ - public toStringInert(syntax?: ShellSyntax) { + public toStringInert(syntax: ShellSyntax = defaultShellSyntax()) { if (isWindows(syntax)) { return formatCommandLineWindows(this.argv, /^[a-zA-Z0-9._\-+=/:]+$/); } else { @@ -140,8 +140,8 @@ function isWhitespace(char: string): boolean { return char === ' ' || char === '\t'; } -function isWindows(x?: ShellSyntax) { - return x ? x === 'cmd.exe' : process.platform === 'win32'; +function isWindows(x: ShellSyntax) { + return x === 'cmd.exe'; } function parseCommandLinePosix(commandLine: string): string[] { @@ -278,3 +278,20 @@ function formatCommandLineWindows(argv: string[], componentIsSafe: RegExp): stri return escaped; }).join(' '); } + +/** + * @see https://github.com/nodejs/node/blob/b4c5fb4ffbec9f27ba5799070c2e0588b7c7ff0e/lib/child_process.js#L626 + */ +function defaultShellSyntax(): ShellSyntax { + if (process.platform !== 'win32') { + return 'posix'; + } + + const file = process.env.comspec || 'cmd.exe'; + // '/d /s /c' is used only for cmd.exe. + if (/^(?:.*\\)?cmd(?:\.exe)?$/i.test(file)) { + return 'cmd.exe'; + } + + return 'posix'; +} diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts index b43400373..59ba815a8 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/private/exec.ts @@ -36,6 +36,13 @@ export async function execInChildProcess(cmd: CommandLine, options: ExecOptions detached: false, cwd: options.cwd, env: options.env, + + // We are using 'shell: true' on purprose. Traditionally we have allowed shell features in + // this string, so we have to continue to do so into the future. On Windows, this is simply + // necessary to run .bat and .cmd files properly. + // Code scanning tools will flag this as a risk. The input comes from a trusted source, + // so it does not represent a security risk. + shell: true, }); const eventPublisher: EventPublisher = options.eventPublisher ?? ((type, line) => { diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts index 81d80bac7..4f2a8b6bf 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts @@ -417,7 +417,11 @@ export abstract class CloudAssemblySourceBuilder { }; } /** - * Use a directory containing an AWS CDK app as source. + * Use a an AWS CDK app executable as source. + * + * `app` is a command line that will be executed to produce a Cloud Assembly. + * The command line will be executed in a shell, so must be trusted. Use + * the `CommandLine` class to help you build a safe string here if necessary. * * The subprocess will execute in `workingDirectory`, which defaults to * the current process' working directory if not given. @@ -443,6 +447,7 @@ export abstract class CloudAssemblySourceBuilder { * all the CDK's default context sources, and updates will be written to * `cdk.context.json`. * + * @param app - the command line to execute to produce a Cloud Assembly * @param props - additional configuration properties * @returns the CloudAssembly source */ diff --git a/packages/aws-cdk/lib/cxapp/exec.ts b/packages/aws-cdk/lib/cxapp/exec.ts index ac7a35c7e..a7019c7a5 100644 --- a/packages/aws-cdk/lib/cxapp/exec.ts +++ b/packages/aws-cdk/lib/cxapp/exec.ts @@ -116,7 +116,10 @@ export async function execProgram(aws: SdkProvider, ioHelper: IoHelper, config: detached: false, // We are using 'shell: true' on purprose. Traditionally we have allowed shell features in - // this string, so we have to continue to do so into the future. + // this string, so we have to continue to do so into the future. On Windows, this is simply + // necessary to run .bat and .cmd files properly. + // Code scanning tools will flag this as a risk. The input comes from a trusted source, + // so it does not represent a security risk. shell: true, env: { From b151fa70cca43de162d5a5c9a2b14406806139f8 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Thu, 26 Jun 2025 10:58:38 +0200 Subject: [PATCH 14/16] Update docs --- .../lib/api/cloud-assembly/command-line.ts | 31 +++++++++++-------- .../lib/api/cloud-assembly/environment.ts | 5 +++ .../lib/api/cloud-assembly/source-builder.ts | 6 ++-- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts index 788b0ea58..8c5130e66 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line.ts @@ -5,30 +5,35 @@ type ShellSyntax = 'posix' | 'cmd.exe'; /** * 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. + * What syntax we recognize is an attribute of the `parse` and `toString()` operations, + * NOT of the command line itself. Defaults to the current platform's default shell + * syntax. * * Because we start with arbitrary shell strings, we may end up stuffing special * shell syntax inside an `argv: string[]` array, which doesn't necessarily make * a lot of sense. There could be a lot more modeling here to for example tag * `argv` elements as literals or bits of shell syntax so we can render them out - * inert or active. + * inert or active. Making this class do all of that correctly is weeks worth of + * work. Instead, it's going to be mostly concerned with correctly parsing and + * preserving spaces, so that we can correctly handle command lines with spaces + * in them on Windows. * - * Making this class do all of that correctly is weeks worth of work. Instead, - * it's going to be mostly concerned with correctly parsing and preserving spaces, - * so that we can correctly handle command lines with spaces in them on Windows. + * - Windows: emulates the behavior of `cmd.exe` if `cmd.exe` is the default shell, + * or the behavior of a POSIX shell otherwise. + * - POSIX: emulates the behavior of a standard POSIX shell. + * + * For some insight of the hell this is on Windows, see these links: + * + * - + * - */ export class CommandLine { /** * Parse a command line into components. * - * - Windows: emulates the behavior of `cmd.exe`. - * - POSIX: emulates the behavior of a standard POSIX shell. - * - * For some insight of the hell this is on Windows, see these links: - * - * - - * - + * Shell characters will end up as elements of the command-line array without + * being marked as such. `toStringGrouped()` will render them out back again, + * while `toStringInert()` will escape them. */ public static parse(cmdLine: string, syntax: ShellSyntax = defaultShellSyntax()) { const argv = isWindows(syntax) ? parseCommandLineWindows(cmdLine) : parseCommandLinePosix(cmdLine); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts index e09329f52..63ba1ac1f 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/environment.ts @@ -114,6 +114,11 @@ export function spaceAvailableForContext(env: Env, limit: number) { * * - 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. + * + * The behavior of only guessing the interpreter if the command line is a single file name + * is a bit limited: we can't put a `.js` file with arguments in the command line and have + * it work properly. Nevertheless, this is the behavior we have had for a long time and nobody + * has really complained about it, so we'll keep it for now. */ export async function guessExecutable(app: string, debugFn: (msg: string) => Promise): Promise { const commandLine = CommandLine.parse(app); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts index 4f2a8b6bf..875a1ce57 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloud-assembly/source-builder.ts @@ -417,11 +417,11 @@ export abstract class CloudAssemblySourceBuilder { }; } /** - * Use a an AWS CDK app executable as source. + * Use an AWS CDK app executable as source. * * `app` is a command line that will be executed to produce a Cloud Assembly. - * The command line will be executed in a shell, so must be trusted. Use - * the `CommandLine` class to help you build a safe string here if necessary. + * The command will be executed in a shell, so it must come from a trusted source. + * Use the `CommandLine` class to help you build a safe shell string if necessary. * * The subprocess will execute in `workingDirectory`, which defaults to * the current process' working directory if not given. From 573f0153609b54e5475abdd2176262c925fe35c2 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Tue, 1 Jul 2025 13:52:01 +0200 Subject: [PATCH 15/16] Two more places --- packages/aws-cdk/lib/commands/init/init.ts | 6 +- packages/aws-cdk/lib/commands/init/os.ts | 65 ++-------------------- 2 files changed, 7 insertions(+), 64 deletions(-) diff --git a/packages/aws-cdk/lib/commands/init/init.ts b/packages/aws-cdk/lib/commands/init/init.ts index 8265dc849..009d3b820 100644 --- a/packages/aws-cdk/lib/commands/init/init.ts +++ b/packages/aws-cdk/lib/commands/init/init.ts @@ -368,7 +368,7 @@ async function initializeGitRepository(workDir: string) { try { await execute('git', ['init'], { cwd: workDir }); await execute('git', ['add', '.'], { cwd: workDir }); - await execute('git', ['commit', '--message="Initial commit"', '--no-gpg-sign'], { cwd: workDir }); + await execute('git', ['commit', '--message=Initial commit', '--no-gpg-sign'], { cwd: workDir }); } catch { warning('Unable to initialize git repository for your project.'); } @@ -428,7 +428,7 @@ async function postInstallPython(cwd: string) { warning(`Please run '${python} -m venv .venv'!`); info(`Executing ${chalk.green('Creating virtualenv...')}`); try { - await execute(python, ['-m venv', '.venv'], { cwd }); + await execute(python, ['-m', 'venv', '.venv'], { cwd }); } catch { warning('Unable to create virtualenv automatically'); warning(`Please run '${python} -m venv .venv'!`); @@ -470,7 +470,7 @@ function isRoot(dir: string) { async function execute(cmd: string, args: string[], { cwd }: { cwd: string }) { const child = childProcess.spawn(cmd, args, { cwd, - shell: true, + shell: false, stdio: ['ignore', 'pipe', 'inherit'], }); let stdout = ''; diff --git a/packages/aws-cdk/lib/commands/init/os.ts b/packages/aws-cdk/lib/commands/init/os.ts index cbcef01c0..985b63792 100644 --- a/packages/aws-cdk/lib/commands/init/os.ts +++ b/packages/aws-cdk/lib/commands/init/os.ts @@ -1,5 +1,6 @@ import * as child_process from 'child_process'; import { ToolkitError } from '@aws-cdk/toolkit-lib'; +import { CommandLine } from '@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line'; import * as chalk from 'chalk'; import { debug } from '../../logging'; @@ -10,11 +11,10 @@ import { debug } from '../../logging'; * string. */ export async function shell(command: string[]): Promise { - const commandLine = renderCommandLine(command); + const commandLine = new CommandLine(command).toStringGrouped(); debug(`Executing ${chalk.blue(commandLine)}`); - const child = child_process.spawn(command[0], renderArguments(command.slice(1)), { - // Need this for Windows where we want .cmd and .bat to be found as well. - shell: true, + const child = child_process.spawn(command[0], command.slice(1), { + shell: false, stdio: ['ignore', 'pipe', 'inherit'], }); @@ -38,60 +38,3 @@ export async function shell(command: string[]): Promise { }); }); } - -function renderCommandLine(cmd: string[]) { - return renderArguments(cmd).join(' '); -} - -/** - * Render the arguments to include escape characters for each platform. - */ -function renderArguments(cmd: string[]) { - if (process.platform !== 'win32') { - return doRender(cmd, hasAnyChars(' ', '\\', '!', '"', "'", '&', '$'), posixEscape); - } else { - return doRender(cmd, hasAnyChars(' ', '"', '&', '^', '%'), windowsEscape); - } -} - -/** - * Render a UNIX command line - */ -function doRender(cmd: string[], needsEscaping: (x: string) => boolean, doEscape: (x: string) => string): string[] { - return cmd.map(x => needsEscaping(x) ? doEscape(x) : x); -} - -/** - * Return a predicate that checks if a string has any of the indicated chars in it - */ -function hasAnyChars(...chars: string[]): (x: string) => boolean { - return (str: string) => { - return chars.some(c => str.indexOf(c) !== -1); - }; -} - -/** - * Escape a shell argument for POSIX shells - * - * Wrapping in single quotes and escaping single quotes inside will do it for us. - */ -function posixEscape(x: string) { - // Turn ' -> '"'"' - x = x.replace(/'/g, "'\"'\"'"); - return `'${x}'`; -} - -/** - * Escape a shell argument for cmd.exe - * - * This is how to do it right, but I'm not following everything: - * - * https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ - */ -function windowsEscape(x: string): string { - // First surround by double quotes, ignore the part about backslashes - x = `"${x}"`; - // Now escape all special characters - const shellMeta = new Set(['"', '&', '^', '%']); - return x.split('').map(c => shellMeta.has(x) ? '^' + c : c).join(''); -} From 72bf9c4f9cb337577e1249b485171743b7cc4257 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 7 Jul 2025 13:22:23 +0200 Subject: [PATCH 16/16] Exports --- packages/@aws-cdk/toolkit-lib/lib/index.ts | 1 + packages/aws-cdk/lib/commands/init/os.ts | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/index.ts b/packages/@aws-cdk/toolkit-lib/lib/index.ts index 6de451e35..5d8182375 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/index.ts @@ -14,6 +14,7 @@ export * from './payloads'; // Supporting acts export * from './api/aws-auth'; export * from './api/cloud-assembly'; +export * from './api/cloud-assembly/command-line'; export * from './api/io'; export * from './api/tags'; export * from './api/plugin'; diff --git a/packages/aws-cdk/lib/commands/init/os.ts b/packages/aws-cdk/lib/commands/init/os.ts index 985b63792..18ddee2e9 100644 --- a/packages/aws-cdk/lib/commands/init/os.ts +++ b/packages/aws-cdk/lib/commands/init/os.ts @@ -1,6 +1,5 @@ import * as child_process from 'child_process'; -import { ToolkitError } from '@aws-cdk/toolkit-lib'; -import { CommandLine } from '@aws-cdk/toolkit-lib/lib/api/cloud-assembly/command-line'; +import { CommandLine, ToolkitError } from '@aws-cdk/toolkit-lib'; import * as chalk from 'chalk'; import { debug } from '../../logging';