Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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)
})
Expand Down
42 changes: 33 additions & 9 deletions src/services/managed-binary/__tests__/archive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {
Expand All @@ -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<typeof spawn>)
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()
Expand Down Expand Up @@ -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)
}
Expand Down
51 changes: 29 additions & 22 deletions src/services/managed-binary/archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProcessResult> {
return runProcess("powershell", [
"-NoProfile",
"-NonInteractive",
"-EncodedCommand",
encodePowerShellCommand(script),
])
}

export function runProcess(executable: string, args: string[], timeoutMs = 30_000): Promise<ProcessResult> {
return new Promise((resolve, reject) => {
const child = spawn(executable, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] })
Expand Down Expand Up @@ -50,14 +67,13 @@ export async function extractTarXzArchive(archivePath: string, destination: stri

export async function extractZipArchive(archivePath: string, destination: string): Promise<void> {
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
}

Expand All @@ -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 {",
Expand All @@ -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(
Expand Down
Loading