Skip to content

Commit b69ed2d

Browse files
authored
Fix unTar resolving GNU tar on Windows when extracting .zip dependencies (#14812)
* Extract tar command construction for explicit Windows tar resolution * Resolve the Windows tar binary explicitly when extracting dependencies
1 parent 1f4ff18 commit b69ed2d

2 files changed

Lines changed: 261 additions & 35 deletions

File tree

‎package/src/util/tar.ts‎

Lines changed: 99 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,80 @@
11
/*
2-
* tar.ts
3-
*
4-
* Copyright (C) 2020-2022 Posit Software, PBC
5-
*
6-
*/
2+
* tar.ts
3+
*
4+
* Copyright (C) 2020-2026 Posit Software, PBC
5+
*/
76

87
import { info } from "../../../src/deno_ral/log.ts";
98
import { dirname, extname } from "../../../src/deno_ral/path.ts";
9+
import { existsSync } from "../../../src/deno_ral/fs.ts";
10+
import { os } from "../../../src/deno_ral/platform.ts";
11+
12+
export function windowsSystemTar(systemRoot?: string): string {
13+
return `${systemRoot || "C:\\Windows"}\\System32\\tar.exe`;
14+
}
15+
16+
// Git for Windows may put GNU tar on PATH, where it cannot extract ZIP files
17+
// from Windows paths. Prefer the system bsdtar and preserve PATH as a fallback.
18+
export function resolveTarBinary(
19+
os: string,
20+
systemTarPath: string | undefined,
21+
): string {
22+
if (os !== "windows") {
23+
return "tar";
24+
}
25+
return systemTarPath ?? "tar";
26+
}
27+
28+
// ZIP archives need no compression flag; bsdtar detects their format.
29+
export function tarCompressFlag(input: string): string {
30+
const ext = extname(input).toLowerCase();
31+
if (ext === ".xz") {
32+
return "J";
33+
} else if (ext === ".bz2") {
34+
return "j";
35+
} else if (ext === ".zip") {
36+
return "";
37+
}
38+
return "z";
39+
}
40+
41+
export function unTarCommand(
42+
tarBin: string,
43+
input: string,
44+
directory?: string,
45+
): string[] {
46+
const cmd = [tarBin, `-xv${tarCompressFlag(input)}f`, input];
47+
if (directory) {
48+
cmd.push("--directory");
49+
cmd.push(directory);
50+
}
51+
return cmd;
52+
}
53+
54+
export function makeTarballCommand(
55+
tarBin: string,
56+
input: string,
57+
output: string,
58+
changewd: boolean,
59+
): string[] {
60+
const cmd = [tarBin, "czvf", output];
61+
if (changewd) {
62+
cmd.push("-C");
63+
}
64+
cmd.push(input);
65+
if (changewd) {
66+
cmd.push(".");
67+
}
68+
return cmd;
69+
}
70+
71+
function currentTarBinary(): string {
72+
const systemTarPath = windowsSystemTar(Deno.env.get("WINDIR"));
73+
return resolveTarBinary(
74+
os,
75+
existsSync(systemTarPath) ? systemTarPath : undefined,
76+
);
77+
}
1078

1179
export async function makeTarball(
1280
input: string,
@@ -16,25 +84,25 @@ export async function makeTarball(
1684
info("Make Tarball");
1785
info(`Input: ${input}`);
1886
info(`Output: ${output}\n`);
19-
const tarCmd: string[] = [];
20-
tarCmd.push("tar");
21-
tarCmd.push("czvf");
22-
tarCmd.push(output);
23-
if (changewd) {
24-
tarCmd.push("-C");
25-
}
26-
tarCmd.push(input);
27-
if (changewd) {
28-
tarCmd.push(".");
29-
}
87+
const tarCmd = makeTarballCommand(
88+
currentTarBinary(),
89+
input,
90+
output,
91+
changewd,
92+
);
3093

3194
info(tarCmd);
95+
// @ts-expect-error `Deno.run()` is soft-removed as of Deno 2; the runtime keeps it.
3296
const p = Deno.run({
3397
cmd: tarCmd,
3498
});
3599
const status = await p.status();
36100
if (status.code !== 0) {
37-
throw Error("Failure to make tarball");
101+
throw Error(
102+
`Failure to make tarball ${output} using ${
103+
tarCmd[0]
104+
} (exit ${status.code}). Command was: ${tarCmd.join(" ")}`,
105+
);
38106
}
39107
}
40108

@@ -45,30 +113,26 @@ export async function unTar(input: string, directory?: string) {
45113
const cwd = dirname(input);
46114
info(`Cwd: ${cwd}`);
47115

48-
// Properly process the compressions
49-
let compressFlag = "z"; // zip by default
50-
const ext = extname(input);
51-
if (ext === ".xz") {
52-
compressFlag = "J";
53-
} else if (ext === ".bz2") {
54-
compressFlag = "j";
55-
}
56-
57-
const tarCmd: string[] = [];
58-
tarCmd.push("tar");
59-
tarCmd.push(`-xv${compressFlag}f`);
60-
tarCmd.push(input);
61-
if (directory) {
62-
tarCmd.push("--directory");
63-
tarCmd.push(directory);
64-
}
116+
const tarCmd = unTarCommand(currentTarBinary(), input, directory);
65117

118+
info(tarCmd);
119+
// @ts-expect-error `Deno.run()` is soft-removed as of Deno 2; the runtime keeps it.
66120
const p = Deno.run({
67121
cmd: tarCmd,
68122
cwd,
69123
});
70124
const status = await p.status();
71125
if (status.code !== 0) {
72-
throw Error("Failure to untar");
126+
const systemTarPath = windowsSystemTar(Deno.env.get("WINDIR"));
127+
const fallbackNote = os === "windows" && tarCmd[0] === "tar"
128+
? ` ${systemTarPath} was not found, so tar was resolved from PATH.`
129+
: "";
130+
throw Error(
131+
`Failure to untar ${input} using ${
132+
tarCmd[0]
133+
} (exit ${status.code}).${fallbackNote} Command was: ${
134+
tarCmd.join(" ")
135+
}`,
136+
);
73137
}
74138
}

‎tests/unit/tar-command.test.ts‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* tar-command.test.ts
3+
*
4+
* Copyright (C) 2026 Posit Software, PBC
5+
*/
6+
7+
import { unitTest } from "../test.ts";
8+
import { assertEquals } from "testing/asserts";
9+
import {
10+
makeTarballCommand,
11+
resolveTarBinary,
12+
tarCompressFlag,
13+
unTarCommand,
14+
windowsSystemTar,
15+
} from "../../package/src/util/tar.ts";
16+
17+
unitTest("windowsSystemTar - builds the System32 path", async () => {
18+
assertEquals(
19+
windowsSystemTar("C:\\WINDOWS"),
20+
"C:\\WINDOWS\\System32\\tar.exe",
21+
);
22+
});
23+
24+
unitTest(
25+
"windowsSystemTar - falls back to C:\\Windows when unset or empty",
26+
async () => {
27+
assertEquals(windowsSystemTar(), "C:\\Windows\\System32\\tar.exe");
28+
assertEquals(windowsSystemTar(""), "C:\\Windows\\System32\\tar.exe");
29+
},
30+
);
31+
32+
unitTest("windowsSystemTar - honours a relocated system root", async () => {
33+
assertEquals(windowsSystemTar("D:\\Win"), "D:\\Win\\System32\\tar.exe");
34+
});
35+
36+
unitTest(
37+
"resolveTarBinary - windows prefers System32 bsdtar when present",
38+
async () => {
39+
assertEquals(
40+
resolveTarBinary("windows", "C:\\WINDOWS\\System32\\tar.exe"),
41+
"C:\\WINDOWS\\System32\\tar.exe",
42+
);
43+
},
44+
);
45+
46+
unitTest(
47+
"resolveTarBinary - windows falls back to PATH when System32 tar is absent",
48+
async () => {
49+
assertEquals(resolveTarBinary("windows", undefined), "tar");
50+
},
51+
);
52+
53+
unitTest(
54+
"resolveTarBinary - other platforms keep the bare binary",
55+
async () => {
56+
assertEquals(resolveTarBinary("linux", "irrelevant"), "tar");
57+
assertEquals(resolveTarBinary("linux", undefined), "tar");
58+
assertEquals(resolveTarBinary("darwin", "irrelevant"), "tar");
59+
},
60+
);
61+
62+
unitTest("tarCompressFlag - zip gets no compression flag", async () => {
63+
assertEquals(tarCompressFlag("dart-sass-1.101.0-windows-x64.zip"), "");
64+
assertEquals(tarCompressFlag("typst-x86_64-pc-windows-msvc.zip"), "");
65+
assertEquals(
66+
tarCompressFlag("typst-gather-x86_64-pc-windows-msvc.zip"),
67+
"",
68+
);
69+
});
70+
71+
unitTest("tarCompressFlag - zip detection is case insensitive", async () => {
72+
assertEquals(tarCompressFlag("ARCHIVE.ZIP"), "");
73+
});
74+
75+
unitTest("tarCompressFlag - xz and bz2 keep their own flags", async () => {
76+
assertEquals(tarCompressFlag("typst-x86_64-unknown-linux-musl.tar.xz"), "J");
77+
assertEquals(tarCompressFlag("something.tar.bz2"), "j");
78+
});
79+
80+
unitTest("tarCompressFlag - gzip forms stay gzip", async () => {
81+
assertEquals(tarCompressFlag("dart-sass-1.101.0-linux-x64.tar.gz"), "z");
82+
assertEquals(tarCompressFlag("esbuild-win32-x64.tgz"), "z");
83+
assertEquals(
84+
tarCompressFlag("typst-gather-x86_64-unknown-linux-gnu.tar.gz"),
85+
"z",
86+
);
87+
});
88+
89+
const kSystemTar = "C:\\WINDOWS\\System32\\tar.exe";
90+
91+
unitTest("unTarCommand - windows zip gets no gzip flag", async () => {
92+
assertEquals(
93+
unTarCommand(kSystemTar, "C:\\tools\\typst-x86_64-pc-windows-msvc.zip"),
94+
[kSystemTar, "-xvf", "C:\\tools\\typst-x86_64-pc-windows-msvc.zip"],
95+
);
96+
});
97+
98+
unitTest("unTarCommand - linux tar.gz is unchanged from today", async () => {
99+
assertEquals(
100+
unTarCommand("tar", "/tools/dart-sass-1.101.0-linux-x64.tar.gz"),
101+
["tar", "-xvzf", "/tools/dart-sass-1.101.0-linux-x64.tar.gz"],
102+
);
103+
});
104+
105+
unitTest("unTarCommand - darwin tar.xz keeps the J flag", async () => {
106+
assertEquals(
107+
unTarCommand("tar", "/tools/typst-x86_64-apple-darwin.tar.xz"),
108+
["tar", "-xvJf", "/tools/typst-x86_64-apple-darwin.tar.xz"],
109+
);
110+
});
111+
112+
unitTest("unTarCommand - a directory appends --directory", async () => {
113+
assertEquals(
114+
unTarCommand(
115+
kSystemTar,
116+
"C:\\tools\\dart-sass-1.101.0-windows-x64.zip",
117+
"C:\\tools\\x86_64",
118+
),
119+
[
120+
kSystemTar,
121+
"-xvf",
122+
"C:\\tools\\dart-sass-1.101.0-windows-x64.zip",
123+
"--directory",
124+
"C:\\tools\\x86_64",
125+
],
126+
);
127+
});
128+
129+
unitTest(
130+
"makeTarballCommand - darwin form is unchanged from today",
131+
async () => {
132+
assertEquals(
133+
makeTarballCommand("tar", "/src/payload", "/out/bundle.tar.gz", false),
134+
["tar", "czvf", "/out/bundle.tar.gz", "/src/payload"],
135+
);
136+
},
137+
);
138+
139+
unitTest(
140+
"makeTarballCommand - changewd wraps the input with -C and dot",
141+
async () => {
142+
assertEquals(
143+
makeTarballCommand("tar", "/src/payload", "/out/bundle.tar.gz", true),
144+
["tar", "czvf", "/out/bundle.tar.gz", "-C", "/src/payload", "."],
145+
);
146+
},
147+
);
148+
149+
unitTest(
150+
"makeTarballCommand - carries whatever binary it is handed",
151+
async () => {
152+
assertEquals(
153+
makeTarballCommand(
154+
kSystemTar,
155+
"C:\\src\\payload",
156+
"C:\\out\\bundle.tar.gz",
157+
false,
158+
),
159+
[kSystemTar, "czvf", "C:\\out\\bundle.tar.gz", "C:\\src\\payload"],
160+
);
161+
},
162+
);

0 commit comments

Comments
 (0)