diff --git a/src/helpers/downloadUtils.js b/src/helpers/downloadUtils.js index 33b74d076..3602e6cb5 100644 --- a/src/helpers/downloadUtils.js +++ b/src/helpers/downloadUtils.js @@ -413,6 +413,11 @@ async function checkDiskSpace(directory, requiredBytes) { } } +/** Escape a path for embedding in a PowerShell single-quoted string. */ +function escapePowerShellSingleQuoted(value) { + return String(value).replace(/'/g, "''"); +} + async function extractZipWindows(zipPath, destDir) { try { await runSystemTar(zipPath, destDir); @@ -421,14 +426,17 @@ async function extractZipWindows(zipPath, destDir) { debugLogger.info("tar extraction failed, trying PowerShell", { error: error.message }); } + // Use -LiteralPath + single-quoted embedding (apostrophes doubled) so paths + // with spaces, brackets, or apostrophes (e.g. C:\Users\O'Brien\...) work. + // Double-quoted -Path embedding breaks on apostrophes when routed via cmd. + const command = + `Expand-Archive -Force -LiteralPath '${escapePowerShellSingleQuoted(zipPath)}' ` + + `-DestinationPath '${escapePowerShellSingleQuoted(destDir)}'`; + return new Promise((resolve, reject) => { execFile( - "powershell", - [ - "-NoProfile", - "-Command", - `Expand-Archive -Force -Path '${zipPath}' -DestinationPath '${destDir}'`, - ], + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command], (psError) => { if (psError) reject(new Error(`Zip extraction failed: ${psError.message}`)); else resolve(); @@ -513,4 +521,5 @@ module.exports = { extractArchive, findFile, findFiles, + escapePowerShellSingleQuoted, }; diff --git a/test/helpers/extractArchive.test.js b/test/helpers/extractArchive.test.js index ff641d8c2..ea19c18be 100644 --- a/test/helpers/extractArchive.test.js +++ b/test/helpers/extractArchive.test.js @@ -176,11 +176,14 @@ test("extractArchive falls back to PowerShell when Windows tar rejects a zip arc assert.deepEqual(systemTarCalls, [[archivePath, destDir]]); assert.deepEqual(execCalls, [ { - command: "powershell", + command: "powershell.exe", args: [ "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", "-Command", - "Expand-Archive -Force -Path 'C:\\cache\\binary.zip' -DestinationPath 'C:\\cache\\extract'", + "Expand-Archive -Force -LiteralPath 'C:\\cache\\binary.zip' -DestinationPath 'C:\\cache\\extract'", ], }, ]); @@ -190,3 +193,50 @@ test("extractArchive falls back to PowerShell when Windows tar rejects a zip arc delete require.cache[downloadUtilsPath]; } }); + +test("escapePowerShellSingleQuoted doubles apostrophes for Expand-Archive paths", () => { + delete require.cache[downloadUtilsPath]; + const { escapePowerShellSingleQuoted } = freshRequire(); + assert.equal( + escapePowerShellSingleQuoted("C:\\Users\\O'Brien\\cache\\binary.zip"), + "C:\\Users\\O''Brien\\cache\\binary.zip" + ); +}); + +test("extractArchive PowerShell fallback quotes apostrophe paths safely", async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + const originalExecFile = cp.execFile; + const execCalls = []; + + Object.defineProperty(process, "platform", { value: "win32" }); + cp.execFile = (command, args, callback) => { + execCalls.push({ command, args }); + callback(null); + }; + + const archivePath = "C:\\Users\\O'Brien\\cache\\binary.zip"; + const destDir = "C:\\Users\\O'Brien\\cache\\extract"; + const { extractArchive } = freshRequire({ + runSystemTar: async () => { + throw new Error("tar extraction timed out"); + }, + }); + + try { + await extractArchive(archivePath, destDir); + assert.equal(execCalls.length, 1); + assert.equal(execCalls[0].command, "powershell.exe"); + assert.deepEqual(execCalls[0].args, [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + "Expand-Archive -Force -LiteralPath 'C:\\Users\\O''Brien\\cache\\binary.zip' -DestinationPath 'C:\\Users\\O''Brien\\cache\\extract'", + ]); + } finally { + cp.execFile = originalExecFile; + Object.defineProperty(process, "platform", originalPlatform); + delete require.cache[downloadUtilsPath]; + } +});