From 2288988f288f09c67f3c8693640a754730bcae2b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 3 Apr 2026 17:21:07 +0900 Subject: [PATCH] fix(zip): use zipinfo to preflight zip extraction on unix Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/zip-entry-listing.ts | 185 ++---------------- .../powershell-zip-entry-listing.ts | 78 ++++++++ .../python-zip-entry-listing.ts | 55 ++++++ .../read-zip-symlink-target.ts | 23 +++ .../tar-zip-entry-listing.ts | 53 +++++ .../zipinfo-zip-entry-listing.test.ts | 22 +++ .../zipinfo-zip-entry-listing.ts | 72 +++++++ src/shared/zip-extractor.ts | 33 ++-- 8 files changed, 336 insertions(+), 185 deletions(-) create mode 100644 src/shared/zip-entry-listing/powershell-zip-entry-listing.ts create mode 100644 src/shared/zip-entry-listing/python-zip-entry-listing.ts create mode 100644 src/shared/zip-entry-listing/read-zip-symlink-target.ts create mode 100644 src/shared/zip-entry-listing/tar-zip-entry-listing.ts create mode 100644 src/shared/zip-entry-listing/zipinfo-zip-entry-listing.test.ts create mode 100644 src/shared/zip-entry-listing/zipinfo-zip-entry-listing.ts diff --git a/src/shared/zip-entry-listing.ts b/src/shared/zip-entry-listing.ts index 299ca4452..d8c99c530 100644 --- a/src/shared/zip-entry-listing.ts +++ b/src/shared/zip-entry-listing.ts @@ -1,172 +1,13 @@ -import { spawn, spawnSync } from "bun" - -import type { ArchiveEntry } from "./archive-entry-validator" - -function parseTarListedZipEntry(line: string): ArchiveEntry | null { - const match = line.match(/^([^\s])\S*\s+\d+\s+\S+\s+\S+\s+\d+\s+\w+\s+\d+\s+(?:\d{2}:\d{2}|\d{4})\s+(.*)$/) - if (!match) { - return null - } - - const [, rawType, rawEntryPath] = match - if (rawType === "l") { - const arrowIndex = rawEntryPath.lastIndexOf(" -> ") - return { - path: arrowIndex === -1 ? rawEntryPath : rawEntryPath.slice(0, arrowIndex), - type: "symlink", - linkPath: arrowIndex === -1 ? undefined : rawEntryPath.slice(arrowIndex + 4), - } - } - - return { - path: rawEntryPath, - type: rawType === "d" ? "directory" : "file", - } -} - -export async function listZipEntriesWithTar(archivePath: string): Promise { - const proc = spawn(["tar", "-tvf", archivePath], { - stdout: "pipe", - stderr: "pipe", - }) - - const [exitCode, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]) - - if (exitCode !== 0) { - throw new Error(`zip entry listing failed (exit ${exitCode}): ${stderr}`) - } - - return stdout - .split(/\r?\n/) - .map(line => line.trim()) - .filter(Boolean) - .map(line => parseTarListedZipEntry(line)) - .filter((entry): entry is ArchiveEntry => entry !== null) -} - -export function isPythonZipListingAvailable(): boolean { - const proc = spawnSync(["python3", "--version"], { - stdout: "ignore", - stderr: "ignore", - }) - - return proc.exitCode === 0 -} - -export async function listZipEntriesWithPython(archivePath: string): Promise { - const script = [ - "import json, stat, sys, zipfile", - "entries = []", - "with zipfile.ZipFile(sys.argv[1], 'r') as archive:", - " for info in archive.infolist():", - " mode = (info.external_attr >> 16) & 0xFFFF", - " if stat.S_ISLNK(mode):", - " entry_type = 'symlink'", - " link_path = archive.read(info).decode('utf-8', 'surrogateescape')", - " elif info.filename.endswith('/'):", - " entry_type = 'directory'", - " link_path = None", - " else:", - " entry_type = 'file'", - " link_path = None", - " entry = {'path': info.filename, 'type': entry_type}", - " if link_path is not None:", - " entry['linkPath'] = link_path", - " entries.append(entry)", - "print(json.dumps(entries))", - ].join("\n") - - const proc = spawn(["python3", "-c", script, archivePath], { - stdout: "pipe", - stderr: "pipe", - }) - - const [exitCode, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]) - - if (exitCode !== 0) { - throw new Error(`zip entry listing failed (exit ${exitCode}): ${stderr}`) - } - - return JSON.parse(stdout) as ArchiveEntry[] -} - -export async function listZipEntriesWithPowerShell( - archivePath: string, - escapePowerShellPath: (path: string) => string, - extractor: "pwsh" | "powershell" -): Promise { - const proc = spawn( - [ - extractor, - "-Command", - [ - "Add-Type -AssemblyName System.IO.Compression.FileSystem", - `$archive = [System.IO.Compression.ZipFile]::OpenRead('${escapePowerShellPath(archivePath)}')`, - "try {", - " foreach ($entry in $archive.Entries) {", - " $mode = ($entry.ExternalAttributes -shr 16) -band 0xFFFF", - " $type = if (($mode -band 0xF000) -eq 0xA000) { 'symlink' } elseif ($entry.FullName.EndsWith('/')) { 'directory' } else { 'file' }", - " $target = ''", - " if ($type -eq 'symlink') {", - " $stream = $entry.Open()", - " try {", - " $reader = New-Object System.IO.StreamReader($stream)", - " try { $target = $reader.ReadToEnd() } finally { $reader.Dispose() }", - " } finally { $stream.Dispose() }", - " }", - " Write-Output ($type + \"`t\" + $entry.FullName + \"`t\" + $target)", - " }", - "} finally {", - " $archive.Dispose()", - "}", - ].join("; "), - ], - { - stdout: "pipe", - stderr: "pipe", - } - ) - - const [exitCode, stdout, stderr] = await Promise.all([ - proc.exited, - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]) - - if (exitCode !== 0) { - throw new Error(`zip entry listing failed (exit ${exitCode}): ${stderr}`) - } - - return stdout - .split(/\r?\n/) - .map(line => line.trim()) - .filter(Boolean) - .map((line): ArchiveEntry | null => { - const [type, entryPath, linkPath = ""] = line.split("\t") - if (type !== "file" && type !== "directory" && type !== "symlink") { - return null - } - - if (type === "symlink") { - return { - path: entryPath, - type, - linkPath, - } - } - - return { - path: entryPath, - type, - } - }) - .filter((entry): entry is ArchiveEntry => entry !== null) -} +export { + isPythonZipListingAvailable, + listZipEntriesWithPython, +} from "./zip-entry-listing/python-zip-entry-listing" +export { + listZipEntriesWithPowerShell, + type PowerShellZipExtractor, +} from "./zip-entry-listing/powershell-zip-entry-listing" +export { listZipEntriesWithTar } from "./zip-entry-listing/tar-zip-entry-listing" +export { + isZipInfoZipListingAvailable, + listZipEntriesWithZipInfo, +} from "./zip-entry-listing/zipinfo-zip-entry-listing" diff --git a/src/shared/zip-entry-listing/powershell-zip-entry-listing.ts b/src/shared/zip-entry-listing/powershell-zip-entry-listing.ts new file mode 100644 index 000000000..d1c9558e9 --- /dev/null +++ b/src/shared/zip-entry-listing/powershell-zip-entry-listing.ts @@ -0,0 +1,78 @@ +import { spawn } from "bun" + +import type { ArchiveEntry } from "../archive-entry-validator" + +export type PowerShellZipExtractor = "pwsh" | "powershell" + +export async function listZipEntriesWithPowerShell( + archivePath: string, + escapePowerShellPath: (path: string) => string, + extractor: PowerShellZipExtractor +): Promise { + const proc = spawn( + [ + extractor, + "-Command", + [ + "Add-Type -AssemblyName System.IO.Compression.FileSystem", + `$archive = [System.IO.Compression.ZipFile]::OpenRead('${escapePowerShellPath(archivePath)}')`, + "try {", + " foreach ($entry in $archive.Entries) {", + " $mode = ($entry.ExternalAttributes -shr 16) -band 0xFFFF", + " $type = if (($mode -band 0xF000) -eq 0xA000) { 'symlink' } elseif ($entry.FullName.EndsWith('/')) { 'directory' } else { 'file' }", + " $target = ''", + " if ($type -eq 'symlink') {", + " $stream = $entry.Open()", + " try {", + " $reader = New-Object System.IO.StreamReader($stream)", + " try { $target = $reader.ReadToEnd() } finally { $reader.Dispose() }", + " } finally { $stream.Dispose() }", + " }", + " Write-Output ($type + \"`t\" + $entry.FullName + \"`t\" + $target)", + " }", + "} finally {", + " $archive.Dispose()", + "}", + ].join("; "), + ], + { + stdout: "pipe", + stderr: "pipe", + } + ) + + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + if (exitCode !== 0) { + throw new Error(`zip entry listing failed (exit ${exitCode}): ${stderr}`) + } + + return stdout + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .map((line): ArchiveEntry | null => { + const [type, entryPath, linkPath = ""] = line.split("\t") + if (type !== "file" && type !== "directory" && type !== "symlink") { + return null + } + + if (type === "symlink") { + return { + path: entryPath, + type, + linkPath, + } + } + + return { + path: entryPath, + type, + } + }) + .filter((entry): entry is ArchiveEntry => entry !== null) +} diff --git a/src/shared/zip-entry-listing/python-zip-entry-listing.ts b/src/shared/zip-entry-listing/python-zip-entry-listing.ts new file mode 100644 index 000000000..8c94442aa --- /dev/null +++ b/src/shared/zip-entry-listing/python-zip-entry-listing.ts @@ -0,0 +1,55 @@ +import { spawn, spawnSync } from "bun" + +import type { ArchiveEntry } from "../archive-entry-validator" + +export function isPythonZipListingAvailable(): boolean { + const proc = spawnSync(["python3", "--version"], { + stdout: "ignore", + stderr: "ignore", + }) + + return proc.exitCode === 0 +} + +export async function listZipEntriesWithPython( + archivePath: string +): Promise { + const script = [ + "import json, stat, sys, zipfile", + "entries = []", + "with zipfile.ZipFile(sys.argv[1], 'r') as archive:", + " for info in archive.infolist():", + " mode = (info.external_attr >> 16) & 0xFFFF", + " if stat.S_ISLNK(mode):", + " entry_type = 'symlink'", + " link_path = archive.read(info).decode('utf-8', 'surrogateescape')", + " elif info.filename.endswith('/'):", + " entry_type = 'directory'", + " link_path = None", + " else:", + " entry_type = 'file'", + " link_path = None", + " entry = {'path': info.filename, 'type': entry_type}", + " if link_path is not None:", + " entry['linkPath'] = link_path", + " entries.append(entry)", + "print(json.dumps(entries))", + ].join("\n") + + const proc = spawn(["python3", "-c", script, archivePath], { + stdout: "pipe", + stderr: "pipe", + }) + + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + if (exitCode !== 0) { + throw new Error(`zip entry listing failed (exit ${exitCode}): ${stderr}`) + } + + return JSON.parse(stdout) as ArchiveEntry[] +} diff --git a/src/shared/zip-entry-listing/read-zip-symlink-target.ts b/src/shared/zip-entry-listing/read-zip-symlink-target.ts new file mode 100644 index 000000000..59eb6098c --- /dev/null +++ b/src/shared/zip-entry-listing/read-zip-symlink-target.ts @@ -0,0 +1,23 @@ +import { spawn } from "bun" + +export async function readZipSymlinkTarget( + archivePath: string, + entryPath: string +): Promise { + const proc = spawn(["unzip", "-p", archivePath, "--", entryPath], { + stdout: "pipe", + stderr: "pipe", + }) + + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + if (exitCode !== 0) { + throw new Error(`zip symlink target read failed (exit ${exitCode}): ${stderr}`) + } + + return stdout || undefined +} diff --git a/src/shared/zip-entry-listing/tar-zip-entry-listing.ts b/src/shared/zip-entry-listing/tar-zip-entry-listing.ts new file mode 100644 index 000000000..8aec02c3b --- /dev/null +++ b/src/shared/zip-entry-listing/tar-zip-entry-listing.ts @@ -0,0 +1,53 @@ +import { spawn } from "bun" + +import type { ArchiveEntry } from "../archive-entry-validator" + +function parseTarListedZipEntry(line: string): ArchiveEntry | null { + const match = line.match( + /^([^\s])\S*\s+\d+\s+\S+\s+\S+\s+\d+\s+\w+\s+\d+\s+(?:\d{2}:\d{2}|\d{4})\s+(.*)$/ + ) + if (!match) { + return null + } + + const [, rawType, rawEntryPath] = match + if (rawType === "l") { + const arrowIndex = rawEntryPath.lastIndexOf(" -> ") + return { + path: arrowIndex === -1 ? rawEntryPath : rawEntryPath.slice(0, arrowIndex), + type: "symlink", + linkPath: arrowIndex === -1 ? undefined : rawEntryPath.slice(arrowIndex + 4), + } + } + + return { + path: rawEntryPath, + type: rawType === "d" ? "directory" : "file", + } +} + +export async function listZipEntriesWithTar( + archivePath: string +): Promise { + const proc = spawn(["tar", "-tvf", archivePath], { + stdout: "pipe", + stderr: "pipe", + }) + + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + if (exitCode !== 0) { + throw new Error(`zip entry listing failed (exit ${exitCode}): ${stderr}`) + } + + return stdout + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .map(line => parseTarListedZipEntry(line)) + .filter((entry): entry is ArchiveEntry => entry !== null) +} diff --git a/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.test.ts b/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.test.ts new file mode 100644 index 000000000..f78e55db2 --- /dev/null +++ b/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" + +import { parseZipInfoListedEntry } from "./zipinfo-zip-entry-listing" + +describe("parseZipInfoListedEntry", () => { + describe("#given a zipinfo listing line with trailing filename whitespace", () => { + it("#when parsing the line #then preserves the original trailing whitespace", () => { + // given + const listedLine = + "-rw-a-- 2.0 fat 4 b- defN 03-Apr-26 12:34 trailing-space.txt " + + // when + const parsedEntry = parseZipInfoListedEntry(listedLine) + + // then + expect(parsedEntry).toEqual({ + path: "trailing-space.txt ", + type: "file", + }) + }) + }) +}) diff --git a/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.ts b/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.ts new file mode 100644 index 000000000..8f520a4ac --- /dev/null +++ b/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.ts @@ -0,0 +1,72 @@ +import { spawn, spawnSync } from "bun" + +import type { ArchiveEntry } from "../archive-entry-validator" +import { readZipSymlinkTarget } from "./read-zip-symlink-target" + +export function parseZipInfoListedEntry(line: string): ArchiveEntry | null { + const match = line.match( + /^([dl-])\S*\s+\S+\s+\S+\s+\d+\s+\S+\s+\d+\s+\S+\s+\S+\s+\S+\s+(.*)$/ + ) + if (!match) { + return null + } + + const [, rawType, rawEntryPath] = match + return { + path: rawEntryPath, + type: rawType === "d" ? "directory" : rawType === "l" ? "symlink" : "file", + } +} + +export function isZipInfoZipListingAvailable(): boolean { + const proc = spawnSync(["which", "zipinfo"], { + stdout: "ignore", + stderr: "ignore", + }) + + return proc.exitCode === 0 +} + +function splitZipInfoOutputLines(stdout: string): string[] { + return stdout.split(/\r?\n/).filter(line => line.length > 0) +} + +export async function listZipEntriesWithZipInfo( + archivePath: string +): Promise { + if (!isZipInfoZipListingAvailable()) { + throw new Error("zip entry listing requires zipinfo, but zipinfo is not installed") + } + + const proc = spawn(["zipinfo", "-l", archivePath], { + stdout: "pipe", + stderr: "pipe", + }) + + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + if (exitCode !== 0) { + throw new Error(`zip entry listing failed (exit ${exitCode}): ${stderr}`) + } + + const parsedEntries = splitZipInfoOutputLines(stdout) + .map(line => parseZipInfoListedEntry(line)) + .filter((entry): entry is ArchiveEntry => entry !== null) + + return Promise.all( + parsedEntries.map(async entry => { + if (entry.type !== "symlink") { + return entry + } + + return { + ...entry, + linkPath: await readZipSymlinkTarget(archivePath, entry.path), + } + }) + ) +} diff --git a/src/shared/zip-extractor.ts b/src/shared/zip-extractor.ts index 8bb77b42c..77ac26b3d 100644 --- a/src/shared/zip-extractor.ts +++ b/src/shared/zip-extractor.ts @@ -3,10 +3,13 @@ import { release } from "os" import { validateArchiveEntries } from "./archive-entry-validator" import { - isPythonZipListingAvailable, - listZipEntriesWithPowerShell, - listZipEntriesWithPython, - listZipEntriesWithTar, + isPythonZipListingAvailable, + isZipInfoZipListingAvailable, + type PowerShellZipExtractor, + listZipEntriesWithPowerShell, + listZipEntriesWithPython, + listZipEntriesWithTar, + listZipEntriesWithZipInfo, } from "./zip-entry-listing" const WINDOWS_BUILD_WITH_TAR = 17134 @@ -32,9 +35,7 @@ function escapePowerShellPath(path: string): string { return path.replace(/'/g, "''") } -type WindowsZipExtractor = "tar" | "pwsh" | "powershell" - -function getWindowsZipExtractor(): WindowsZipExtractor { +function getWindowsZipExtractor(): "tar" | PowerShellZipExtractor { const buildNumber = getWindowsBuildNumber() if (buildNumber !== null && buildNumber >= WINDOWS_BUILD_WITH_TAR) { @@ -94,8 +95,8 @@ export async function extractZip(archivePath: string, destDir: string): Promise< } async function listZipEntries(archivePath: string) { - if (process.platform === "win32") { - const extractor = getWindowsZipExtractor() + if (process.platform === "win32") { + const extractor = getWindowsZipExtractor() if (extractor === "tar") { return listZipEntriesWithTar(archivePath) } @@ -103,9 +104,15 @@ async function listZipEntries(archivePath: string) { return listZipEntriesWithPowerShell(archivePath, escapePowerShellPath, extractor) } - if (isPythonZipListingAvailable()) { - return listZipEntriesWithPython(archivePath) - } + if (isPythonZipListingAvailable()) { + return listZipEntriesWithPython(archivePath) + } - return listZipEntriesWithTar(archivePath) + if (isZipInfoZipListingAvailable()) { + return listZipEntriesWithZipInfo(archivePath) + } + + throw new Error( + "zip entry listing requires either python3 or zipinfo to inspect the archive safely" + ) }