diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 16a2927a..b814aba1 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -196,7 +196,10 @@ jobs: bun run build - name: Verify managed Windows launcher - run: bun test src/main/runtime/managed-launcher.test.ts src/main/runtime/windows-launcher-bootstrap.test.ts + run: bun test src/main/runtime/managed-launcher.test.ts + + - name: Verify Windows launcher bootstrap + run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts - name: Build unsigned Windows artifacts run: bun run build:win:unsigned diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 042ac4d5..50c69c3e 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -23,8 +23,14 @@ jobs: with: bun-version: 1.3.5 - - name: Verify native launcher bootstrap and runtime staging - run: bun test src/main/runtime/managed-launcher.test.ts src/main/runtime/windows-launcher-bootstrap.test.ts src/main/runtime/posix-launcher-bootstrap.test.ts + - name: Verify native runtime staging + run: bun test src/main/runtime/managed-launcher.test.ts + + - name: Verify Windows launcher bootstrap + run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts + + - name: Verify POSIX launcher bootstrap + run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts quality-gate: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d18c8197..c0af5cfe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -195,7 +195,10 @@ jobs: bun run build - name: Verify managed Windows launcher - run: bun test src/main/runtime/managed-launcher.test.ts src/main/runtime/windows-launcher-bootstrap.test.ts + run: bun test src/main/runtime/managed-launcher.test.ts + + - name: Verify Windows launcher bootstrap + run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts - name: Build unsigned Windows artifacts run: bun run build:win:unsigned diff --git a/src/main/runtime/command-line-launcher-deps.ts b/src/main/runtime/command-line-launcher-deps.ts index c5172584..58f01693 100644 --- a/src/main/runtime/command-line-launcher-deps.ts +++ b/src/main/runtime/command-line-launcher-deps.ts @@ -145,8 +145,40 @@ function needsWindowsShell(command: string): boolean { return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command); } -function quoteForWindowsShell(value: string): string { - return `"${value.replace(/([&|<>^%!])/g, '^$1').replace(/"/g, '""')}"`; +/*! + * Windows command escaping adapted from cross-spawn 7.0.6. + * + * The MIT License (MIT) + * + * Copyright (c) 2018 Made With MOXY Lda + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +const WINDOWS_SHELL_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/g; + +// Quote for both cmd.exe and the Windows argv parser. The outer caret escapes +// are consumed by cmd, leaving the quoted argument unchanged for the command. +function escapeWindowsShellArgument(value: string): string { + const quotesEscaped = value + .replace(/(?=(\\+?)?)\1"/g, '$1$1\\"') + .replace(/(?=(\\+?)?)\1$/, '$1$1'); + return `"${quotesEscaped}"`.replace(WINDOWS_SHELL_META_CHARACTERS, '^$1'); } function createDefaultRunCommand(): RunCommand { @@ -155,16 +187,24 @@ function createDefaultRunCommand(): RunCommand { const useShell = needsWindowsShell(command); let child: ReturnType; try { - child = useShell - ? spawn(quoteForWindowsShell(command), args.map(quoteForWindowsShell), { - env: options.env ?? process.env, - windowsHide: false, - shell: true, - }) - : spawn(command, args, { - env: options.env ?? process.env, - windowsHide: false, - }); + const env = options.env ?? process.env; + if (useShell) { + const shellCommand = [ + escapeWindowsShellArgument(command), + ...args.map(escapeWindowsShellArgument), + ].join(' '); + const commandProcessor = env.ComSpec ?? env.COMSPEC ?? process.env.ComSpec ?? 'cmd.exe'; + child = spawn(commandProcessor, ['/d', '/s', '/v:off', '/c', `"${shellCommand}"`], { + env, + windowsHide: false, + windowsVerbatimArguments: true, + }); + } else { + child = spawn(command, args, { + env, + windowsHide: false, + }); + } } catch (error) { resolve({ exitCode: 1, diff --git a/src/main/runtime/command-line-launcher.test.ts b/src/main/runtime/command-line-launcher.test.ts index 613c6c06..97e29600 100644 --- a/src/main/runtime/command-line-launcher.test.ts +++ b/src/main/runtime/command-line-launcher.test.ts @@ -91,43 +91,54 @@ test('resolveBunInstallCommand prefers winget on Windows', () => { test('default runCommand preserves Windows cmd metacharacter args', async (t) => { if (process.platform !== 'win32') return; - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-cmd-args-')); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer cmd & 100% ! ')); const scriptPath = path.join(tempDir, 'argv.cmd'); - const outputPath = path.join(tempDir, 'argv.txt'); + const argvScriptPath = path.join(tempDir, 'argv.js'); t.after(() => { fs.rmSync(tempDir, { recursive: true, force: true }); }); + fs.writeFileSync( + argvScriptPath, + 'process.stdout.write(JSON.stringify(process.argv.slice(2)));', + 'utf8', + ); fs.writeFileSync( scriptPath, [ '@echo off', 'setlocal DisableDelayedExpansion', - '> "%SUBMINER_ARGV_OUT%" (', - ' echo 1=%~1', - ' echo 2=%~2', - ' echo 3=%~3', - ' echo 4=%~4', - ' echo 5=%~5', - ' echo 6=%~6', - ')', + '"%SUBMINER_TEST_RUNTIME%" "%SUBMINER_ARGV_SCRIPT%" %*', + 'exit /b %errorlevel%', '', ].join('\r\n'), 'utf8', ); - const result = await getRunCommand({})( - scriptPath, - ['plain', 'has space', 'a&b', 'x|y', 'p%PATH%q', 'bang!z'], - { - env: { ...process.env, SUBMINER_ARGV_OUT: outputPath }, + const args = [ + 'plain', + 'has space', + 'a&b', + 'x|y', + 'p%TEMP%q', + 'bang!z', + 'caret^z', + '', + 'say "hi"', + 'slash\\"quote', + 'trailing\\', + '', + '日本語', + ]; + const result = await getRunCommand({})(scriptPath, args, { + env: { + ...process.env, + SUBMINER_ARGV_SCRIPT: argvScriptPath, + SUBMINER_TEST_RUNTIME: process.execPath, }, - ); + }); assert.equal(result.exitCode, 0, result.stderr); - assert.equal( - fs.readFileSync(outputPath, 'utf8'), - ['1=plain', '2=has space', '3=a&b', '4=x|y', '5=p%PATH%q', '6=bang!z', ''].join('\r\n'), - ); + assert.deepEqual(JSON.parse(result.stdout), args); }); test('resolveBunInstallCommand falls back to scoop on Windows before official installer', () => { diff --git a/src/main/runtime/managed-launcher.test.ts b/src/main/runtime/managed-launcher.test.ts index 6a7aa00c..7f705cda 100644 --- a/src/main/runtime/managed-launcher.test.ts +++ b/src/main/runtime/managed-launcher.test.ts @@ -230,7 +230,7 @@ test('Windows managed launcher forwards arguments without a system Bun', async ( const snapshot = await installLauncher(options); assert.equal(snapshot.status, 'ready', snapshot.message ?? 'install failed'); const { getRunCommand } = await import('./command-line-launcher-deps'); - const args = ['spaces here', 'a&b', 'bang!z', '日本語']; + const args = ['spaces here', 'a&b', 'p%TEMP%q', 'bang!z', 'say "hi"', '日本語']; const result = await getRunCommand({})(snapshot.installPath!, args, { env: options.env }); assert.equal(result.exitCode, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout), args); diff --git a/src/main/runtime/windows-launcher-bootstrap.test.ts b/src/main/runtime/windows-launcher-bootstrap.test.ts index 6a9f50f9..5f17c30c 100644 --- a/src/main/runtime/windows-launcher-bootstrap.test.ts +++ b/src/main/runtime/windows-launcher-bootstrap.test.ts @@ -96,6 +96,7 @@ test('Windows bootstrap prepares once and forwards metacharacter arguments', asy const args = [ 'spaces here', '100%', + 'p%TEMP%q', 'bang!', 'a&b', 'x|y',