From 3de030adeee85946abcf2f617a307b501efb653c Mon Sep 17 00:00:00 2001 From: Naved Date: Wed, 5 Aug 2026 17:40:00 -0700 Subject: [PATCH] fix(semble): encode Windows archive extraction command --- .../__tests__/semble-downloader.spec.ts | 2 +- .../__tests__/manager.spec.ts | 18 +++---- .../managed-binary/__tests__/archive.spec.ts | 42 +++++++++++---- src/services/managed-binary/archive.ts | 51 +++++++++++-------- 4 files changed, 69 insertions(+), 44 deletions(-) diff --git a/src/services/code-index/semble/__tests__/semble-downloader.spec.ts b/src/services/code-index/semble/__tests__/semble-downloader.spec.ts index 7cf7599c89..e487cffbbc 100644 --- a/src/services/code-index/semble/__tests__/semble-downloader.spec.ts +++ b/src/services/code-index/semble/__tests__/semble-downloader.spec.ts @@ -550,7 +550,7 @@ describe("semble-downloader", () => { // Should call PowerShell for zip extraction expect(spawn).toHaveBeenCalledWith( "powershell", - expect.arrayContaining(["-NoProfile", "-Command", expect.stringContaining("Expand-Archive")]), + ["-NoProfile", "-NonInteractive", "-EncodedCommand", expect.any(String)], expect.any(Object), ) // Should NOT call chmod on windows diff --git a/src/services/destructive-command-guard/__tests__/manager.spec.ts b/src/services/destructive-command-guard/__tests__/manager.spec.ts index 15bed8b511..2fa4579549 100644 --- a/src/services/destructive-command-guard/__tests__/manager.spec.ts +++ b/src/services/destructive-command-guard/__tests__/manager.spec.ts @@ -174,14 +174,7 @@ describe("Destructive Command Guard manager", () => { const expectedExecutable = process.platform === "win32" ? "powershell" : "unzip" const expectedArgs = process.platform === "win32" - ? [ - "-NoProfile", - "-NonInteractive", - "-Command", - "$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force", - "C:\\dcg.zip", - "C:\\staging", - ] + ? ["-NoProfile", "-NonInteractive", "-EncodedCommand", expect.any(String)] : ["-o", "C:\\dcg.zip", "-d", "C:\\staging"] expect(mockSpawn).toHaveBeenCalledWith(expectedExecutable, expectedArgs, { @@ -361,10 +354,11 @@ describe("Destructive Command Guard manager", () => { kill: vi.fn(), }) setImmediate(async () => { - const destinationIndex = args.indexOf( - "$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force", - ) - const stagingDir = args[destinationIndex + 2] + const encodedCommandIndex = args.indexOf("-EncodedCommand") + const script = Buffer.from(args[encodedCommandIndex + 1], "base64").toString("utf16le") + const destinationMatch = script.match(/\$destination = '((?:[^']|'')*)'/) + if (!destinationMatch) throw new Error("Expected a PowerShell destination") + const stagingDir = destinationMatch[1].replace(/''/g, "'") await writeFile(path.join(stagingDir, info.binary), "ZIP executable") child.emit("close", 0) }) diff --git a/src/services/managed-binary/__tests__/archive.spec.ts b/src/services/managed-binary/__tests__/archive.spec.ts index cc06e85fd4..89dbcc9bf0 100644 --- a/src/services/managed-binary/__tests__/archive.spec.ts +++ b/src/services/managed-binary/__tests__/archive.spec.ts @@ -17,6 +17,12 @@ vi.mock("child_process", () => ({ spawn: vi.fn() })) const mockSpawn = vi.mocked(spawn) +function decodePowerShellCommand(args: readonly string[]): string { + const encodedCommandIndex = args.indexOf("-EncodedCommand") + if (encodedCommandIndex === -1) throw new Error("Expected an encoded PowerShell command") + return Buffer.from(args[encodedCommandIndex + 1], "base64").toString("utf16le") +} + function createChild() { return Object.assign(new EventEmitter(), { stdout: new PassThrough(), @@ -97,7 +103,7 @@ describe("managed binary archive utilities", () => { if (process.platform === "win32") { expect(mockSpawn).toHaveBeenCalledWith( "powershell", - ["-NoProfile", "-NonInteractive", "-Command", expect.any(String), "/tmp/archive.zip", "/tmp/output"], + ["-NoProfile", "-NonInteractive", "-EncodedCommand", expect.any(String)], expect.objectContaining({ shell: false }), ) } else { @@ -109,6 +115,28 @@ describe("managed binary archive utilities", () => { } }) + it("passes Windows ZIP paths through an encoded PowerShell command", async () => { + const child = createChild() + mockSpawn.mockReturnValue(child as unknown as ReturnType) + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform") + Object.defineProperty(process, "platform", { value: "win32", configurable: true }) + try { + const extraction = extractZipArchive("C:\\Roo's files\\archive.zip", "C:\\Roo's files\\output") + child.emit("close", 0) + await extraction + + const args = mockSpawn.mock.calls[0][1] + expect(args).toEqual(["-NoProfile", "-NonInteractive", "-EncodedCommand", expect.any(String)]) + const script = decodePowerShellCommand(args) + expect(script).toContain("$archivePath = 'C:\\Roo''s files\\archive.zip'") + expect(script).toContain("$destination = 'C:\\Roo''s files\\output'") + expect(script).toContain("Expand-Archive -LiteralPath $archivePath") + expect(script).not.toContain("$args") + } finally { + if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform) + } + }) + it("validates a single-file tar.xz layout before extraction", async () => { const listing = createChild() const extraction = createChild() @@ -165,15 +193,11 @@ describe("managed binary archive utilities", () => { await extraction const args = mockSpawn.mock.calls[0][1] - const script = args[3] + const script = decodePowerShellCommand(args) expect(script).toContain("$entries.Count -ne 1") - expect(script).not.toContain("C:\\archive.zip") - expect(args.slice(4)).toEqual([ - "C:\\archive.zip", - path.join("C:\\output", "binary.exe"), - "binary.exe", - "Tool", - ]) + expect(script).toContain("$archivePath = 'C:\\archive.zip'") + expect(script).toContain(`$outputPath = '${path.join("C:\\output", "binary.exe")}'`) + expect(args).toEqual(["-NoProfile", "-NonInteractive", "-EncodedCommand", expect.any(String)]) } finally { if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform) } diff --git a/src/services/managed-binary/archive.ts b/src/services/managed-binary/archive.ts index 674fe93ebb..be2f29a133 100644 --- a/src/services/managed-binary/archive.ts +++ b/src/services/managed-binary/archive.ts @@ -6,6 +6,23 @@ export interface ProcessResult { stderr: string } +function quotePowerShellString(value: string): string { + return `'${value.replace(/'/g, "''")}'` +} + +function encodePowerShellCommand(script: string): string { + return Buffer.from(script, "utf16le").toString("base64") +} + +async function runPowerShell(script: string): Promise { + return runProcess("powershell", [ + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + encodePowerShellCommand(script), + ]) +} + export function runProcess(executable: string, args: string[], timeoutMs = 30_000): Promise { return new Promise((resolve, reject) => { const child = spawn(executable, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] }) @@ -50,14 +67,13 @@ export async function extractTarXzArchive(archivePath: string, destination: stri export async function extractZipArchive(archivePath: string, destination: string): Promise { if (process.platform === "win32") { - await runProcess("powershell", [ - "-NoProfile", - "-NonInteractive", - "-Command", - "$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force", - archivePath, - destination, - ]) + const script = [ + "$ErrorActionPreference = 'Stop'", + `$archivePath = ${quotePowerShellString(archivePath)}`, + `$destination = ${quotePowerShellString(destination)}`, + "Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force", + ].join("; ") + await runPowerShell(script) return } @@ -76,10 +92,10 @@ export async function extractSingleFileZipArchive( const script = [ "$ErrorActionPreference = 'Stop'", - "$archivePath = $args[0]", - "$outputPath = $args[1]", - "$expectedFile = $args[2]", - "$archiveName = $args[3]", + `$archivePath = ${quotePowerShellString(archivePath)}`, + `$outputPath = ${quotePowerShellString(path.join(destination, expectedFile))}`, + `$expectedFile = ${quotePowerShellString(expectedFile)}`, + `$archiveName = ${quotePowerShellString(archiveName)}`, "Add-Type -AssemblyName System.IO.Compression.FileSystem", "$archive = [System.IO.Compression.ZipFile]::OpenRead($archivePath)", "try {", @@ -89,16 +105,7 @@ export async function extractSingleFileZipArchive( "} finally { $archive.Dispose() }", ].join("; ") - await runProcess("powershell", [ - "-NoProfile", - "-NonInteractive", - "-Command", - script, - archivePath, - path.join(destination, expectedFile), - expectedFile, - archiveName, - ]) + await runPowerShell(script) } export async function extractSingleFileTarXzArchive(