diff --git a/package/src/util/tar.ts b/package/src/util/tar.ts index dcb50d9ca7..2a7524c173 100644 --- a/package/src/util/tar.ts +++ b/package/src/util/tar.ts @@ -1,12 +1,80 @@ /* -* tar.ts -* -* Copyright (C) 2020-2022 Posit Software, PBC -* -*/ + * tar.ts + * + * Copyright (C) 2020-2026 Posit Software, PBC + */ import { info } from "../../../src/deno_ral/log.ts"; import { dirname, extname } from "../../../src/deno_ral/path.ts"; +import { existsSync } from "../../../src/deno_ral/fs.ts"; +import { os } from "../../../src/deno_ral/platform.ts"; + +export function windowsSystemTar(systemRoot?: string): string { + return `${systemRoot || "C:\\Windows"}\\System32\\tar.exe`; +} + +// Git for Windows may put GNU tar on PATH, where it cannot extract ZIP files +// from Windows paths. Prefer the system bsdtar and preserve PATH as a fallback. +export function resolveTarBinary( + os: string, + systemTarPath: string | undefined, +): string { + if (os !== "windows") { + return "tar"; + } + return systemTarPath ?? "tar"; +} + +// ZIP archives need no compression flag; bsdtar detects their format. +export function tarCompressFlag(input: string): string { + const ext = extname(input).toLowerCase(); + if (ext === ".xz") { + return "J"; + } else if (ext === ".bz2") { + return "j"; + } else if (ext === ".zip") { + return ""; + } + return "z"; +} + +export function unTarCommand( + tarBin: string, + input: string, + directory?: string, +): string[] { + const cmd = [tarBin, `-xv${tarCompressFlag(input)}f`, input]; + if (directory) { + cmd.push("--directory"); + cmd.push(directory); + } + return cmd; +} + +export function makeTarballCommand( + tarBin: string, + input: string, + output: string, + changewd: boolean, +): string[] { + const cmd = [tarBin, "czvf", output]; + if (changewd) { + cmd.push("-C"); + } + cmd.push(input); + if (changewd) { + cmd.push("."); + } + return cmd; +} + +function currentTarBinary(): string { + const systemTarPath = windowsSystemTar(Deno.env.get("WINDIR")); + return resolveTarBinary( + os, + existsSync(systemTarPath) ? systemTarPath : undefined, + ); +} export async function makeTarball( input: string, @@ -16,25 +84,25 @@ export async function makeTarball( info("Make Tarball"); info(`Input: ${input}`); info(`Output: ${output}\n`); - const tarCmd: string[] = []; - tarCmd.push("tar"); - tarCmd.push("czvf"); - tarCmd.push(output); - if (changewd) { - tarCmd.push("-C"); - } - tarCmd.push(input); - if (changewd) { - tarCmd.push("."); - } + const tarCmd = makeTarballCommand( + currentTarBinary(), + input, + output, + changewd, + ); info(tarCmd); + // @ts-expect-error `Deno.run()` is soft-removed as of Deno 2; the runtime keeps it. const p = Deno.run({ cmd: tarCmd, }); const status = await p.status(); if (status.code !== 0) { - throw Error("Failure to make tarball"); + throw Error( + `Failure to make tarball ${output} using ${ + tarCmd[0] + } (exit ${status.code}). Command was: ${tarCmd.join(" ")}`, + ); } } @@ -45,30 +113,26 @@ export async function unTar(input: string, directory?: string) { const cwd = dirname(input); info(`Cwd: ${cwd}`); - // Properly process the compressions - let compressFlag = "z"; // zip by default - const ext = extname(input); - if (ext === ".xz") { - compressFlag = "J"; - } else if (ext === ".bz2") { - compressFlag = "j"; - } - - const tarCmd: string[] = []; - tarCmd.push("tar"); - tarCmd.push(`-xv${compressFlag}f`); - tarCmd.push(input); - if (directory) { - tarCmd.push("--directory"); - tarCmd.push(directory); - } + const tarCmd = unTarCommand(currentTarBinary(), input, directory); + info(tarCmd); + // @ts-expect-error `Deno.run()` is soft-removed as of Deno 2; the runtime keeps it. const p = Deno.run({ cmd: tarCmd, cwd, }); const status = await p.status(); if (status.code !== 0) { - throw Error("Failure to untar"); + const systemTarPath = windowsSystemTar(Deno.env.get("WINDIR")); + const fallbackNote = os === "windows" && tarCmd[0] === "tar" + ? ` ${systemTarPath} was not found, so tar was resolved from PATH.` + : ""; + throw Error( + `Failure to untar ${input} using ${ + tarCmd[0] + } (exit ${status.code}).${fallbackNote} Command was: ${ + tarCmd.join(" ") + }`, + ); } } diff --git a/tests/unit/tar-command.test.ts b/tests/unit/tar-command.test.ts new file mode 100644 index 0000000000..daaf197b40 --- /dev/null +++ b/tests/unit/tar-command.test.ts @@ -0,0 +1,162 @@ +/* + * tar-command.test.ts + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assertEquals } from "testing/asserts"; +import { + makeTarballCommand, + resolveTarBinary, + tarCompressFlag, + unTarCommand, + windowsSystemTar, +} from "../../package/src/util/tar.ts"; + +unitTest("windowsSystemTar - builds the System32 path", async () => { + assertEquals( + windowsSystemTar("C:\\WINDOWS"), + "C:\\WINDOWS\\System32\\tar.exe", + ); +}); + +unitTest( + "windowsSystemTar - falls back to C:\\Windows when unset or empty", + async () => { + assertEquals(windowsSystemTar(), "C:\\Windows\\System32\\tar.exe"); + assertEquals(windowsSystemTar(""), "C:\\Windows\\System32\\tar.exe"); + }, +); + +unitTest("windowsSystemTar - honours a relocated system root", async () => { + assertEquals(windowsSystemTar("D:\\Win"), "D:\\Win\\System32\\tar.exe"); +}); + +unitTest( + "resolveTarBinary - windows prefers System32 bsdtar when present", + async () => { + assertEquals( + resolveTarBinary("windows", "C:\\WINDOWS\\System32\\tar.exe"), + "C:\\WINDOWS\\System32\\tar.exe", + ); + }, +); + +unitTest( + "resolveTarBinary - windows falls back to PATH when System32 tar is absent", + async () => { + assertEquals(resolveTarBinary("windows", undefined), "tar"); + }, +); + +unitTest( + "resolveTarBinary - other platforms keep the bare binary", + async () => { + assertEquals(resolveTarBinary("linux", "irrelevant"), "tar"); + assertEquals(resolveTarBinary("linux", undefined), "tar"); + assertEquals(resolveTarBinary("darwin", "irrelevant"), "tar"); + }, +); + +unitTest("tarCompressFlag - zip gets no compression flag", async () => { + assertEquals(tarCompressFlag("dart-sass-1.101.0-windows-x64.zip"), ""); + assertEquals(tarCompressFlag("typst-x86_64-pc-windows-msvc.zip"), ""); + assertEquals( + tarCompressFlag("typst-gather-x86_64-pc-windows-msvc.zip"), + "", + ); +}); + +unitTest("tarCompressFlag - zip detection is case insensitive", async () => { + assertEquals(tarCompressFlag("ARCHIVE.ZIP"), ""); +}); + +unitTest("tarCompressFlag - xz and bz2 keep their own flags", async () => { + assertEquals(tarCompressFlag("typst-x86_64-unknown-linux-musl.tar.xz"), "J"); + assertEquals(tarCompressFlag("something.tar.bz2"), "j"); +}); + +unitTest("tarCompressFlag - gzip forms stay gzip", async () => { + assertEquals(tarCompressFlag("dart-sass-1.101.0-linux-x64.tar.gz"), "z"); + assertEquals(tarCompressFlag("esbuild-win32-x64.tgz"), "z"); + assertEquals( + tarCompressFlag("typst-gather-x86_64-unknown-linux-gnu.tar.gz"), + "z", + ); +}); + +const kSystemTar = "C:\\WINDOWS\\System32\\tar.exe"; + +unitTest("unTarCommand - windows zip gets no gzip flag", async () => { + assertEquals( + unTarCommand(kSystemTar, "C:\\tools\\typst-x86_64-pc-windows-msvc.zip"), + [kSystemTar, "-xvf", "C:\\tools\\typst-x86_64-pc-windows-msvc.zip"], + ); +}); + +unitTest("unTarCommand - linux tar.gz is unchanged from today", async () => { + assertEquals( + unTarCommand("tar", "/tools/dart-sass-1.101.0-linux-x64.tar.gz"), + ["tar", "-xvzf", "/tools/dart-sass-1.101.0-linux-x64.tar.gz"], + ); +}); + +unitTest("unTarCommand - darwin tar.xz keeps the J flag", async () => { + assertEquals( + unTarCommand("tar", "/tools/typst-x86_64-apple-darwin.tar.xz"), + ["tar", "-xvJf", "/tools/typst-x86_64-apple-darwin.tar.xz"], + ); +}); + +unitTest("unTarCommand - a directory appends --directory", async () => { + assertEquals( + unTarCommand( + kSystemTar, + "C:\\tools\\dart-sass-1.101.0-windows-x64.zip", + "C:\\tools\\x86_64", + ), + [ + kSystemTar, + "-xvf", + "C:\\tools\\dart-sass-1.101.0-windows-x64.zip", + "--directory", + "C:\\tools\\x86_64", + ], + ); +}); + +unitTest( + "makeTarballCommand - darwin form is unchanged from today", + async () => { + assertEquals( + makeTarballCommand("tar", "/src/payload", "/out/bundle.tar.gz", false), + ["tar", "czvf", "/out/bundle.tar.gz", "/src/payload"], + ); + }, +); + +unitTest( + "makeTarballCommand - changewd wraps the input with -C and dot", + async () => { + assertEquals( + makeTarballCommand("tar", "/src/payload", "/out/bundle.tar.gz", true), + ["tar", "czvf", "/out/bundle.tar.gz", "-C", "/src/payload", "."], + ); + }, +); + +unitTest( + "makeTarballCommand - carries whatever binary it is handed", + async () => { + assertEquals( + makeTarballCommand( + kSystemTar, + "C:\\src\\payload", + "C:\\out\\bundle.tar.gz", + false, + ), + [kSystemTar, "czvf", "C:\\out\\bundle.tar.gz", "C:\\src\\payload"], + ); + }, +);