Skip to content

Commit 55a313c

Browse files
authored
Fix zip extraction on Windows when Group Policy blocks .bat files (#15202)
On Windows machines where Group Policy blocks running .bat files from %TEMP%, every zip extraction failed: quarto add, quarto use template, quarto use brand and quarto install tinytex. unzip() ran PowerShell and the self-extracting TinyTeX installer through safeWindowsExec, which wraps them in a quarto-safe-exec*.bat file. Both are real executables, so the batch wrapper added nothing. No released version has worked on such machines since 2022. - Launch powershell.exe directly with -NoProfile -NonInteractive, and run the TinyTeX, veraPDF and Chrome for Testing installers without the wrapper. - Pass arguments as UTF-16, so accented and CJK paths need no chcp. - Embed archive and destination paths as PowerShell single-quoted literals, doubling every single quote, including the curly forms. Paths with an apostrophe, such as O'Brien, broke the script before. - Set [Console]::OutputEncoding to UTF-8, so a non-ASCII path in an error message is not shown as '?' on a non-UTF-8 code page. Other safeWindowsExec callers (tlmgr, fmtutil, reg.exe lookups and openUrl) still use the temporary .bat file and are unchanged. Same environment as #14367. Its fix (#14273) moved dart-sass off the temporary .bat file but did not cover unzip. Spaces in the profile path (#2998) were the original reason unzip used safeWindowsExec; quoting the paths in the PowerShell script handles that case now. Fixes #15198
1 parent 4051614 commit 55a313c

4 files changed

Lines changed: 87 additions & 30 deletions

File tree

‎news/changelog-1.11.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,4 @@ All changes included in 1.11:
194194
- ([#15126](https://github.com/quarto-dev/quarto-cli/issues/15126)): Translate the `revealjs-menu-button-label` language key in French. (author: @D3ller)
195195
- ([#15140](https://github.com/quarto-dev/quarto-cli/pull/15140)): Add support for definition lists written as a `definition-list` div holding a nested bullet list, the definition list syntax of Quarto 2.
196196
- ([#15180](https://github.com/quarto-dev/quarto-cli/pull/15180)): Fix older Quarto versions failing to render with `RangeError: could not deserialize value` once a Quarto built on a newer Deno has used the same cache. Also fix a project cache that already has entries failing to clear with `Path must be a string`.
197+
- ([#15198](https://github.com/quarto-dev/quarto-cli/issues/15198)): Fix `quarto add`, `quarto use template`, `quarto use brand` and `quarto install tinytex` failing to unzip on Windows when a Group Policy blocks running `.bat` files from the temporary folder, and when a path contains an apostrophe.

‎src/core/zip.ts‎

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,45 +7,43 @@ import { dirname } from "../deno_ral/path.ts";
77
import { existsSync } from "../deno_ral/fs.ts";
88
import { isWindows } from "../deno_ral/platform.ts";
99
import { execProcess } from "./process.ts";
10-
import { safeWindowsExec } from "./windows.ts";
10+
11+
// Quote a value as a PowerShell single-quoted string literal. PowerShell
12+
// treats ' and the curly quotes ‘ ’ ‚ ‛ all as single quotes; each is
13+
// escaped by doubling it.
14+
function powershellLiteral(value: string) {
15+
return `'${value.replace(/['‘’‚‛]/g, "$&$&")}'`;
16+
}
1117

1218
export function unzip(file: string, dir?: string) {
1319
if (!dir) dir = dirname(file);
1420

21+
// Windows extraction launches executables directly, without a temporary
22+
// .bat: Group Policy can block running .bat files from %TEMP% (#15198)
1523
if (isWindows && file.endsWith(".exe")) {
1624
// Self-extracting 7z archive (e.g., TinyTeX-windows.exe)
17-
return safeWindowsExec(
18-
file,
19-
["-y"],
20-
(cmd: string[]) => {
21-
return execProcess({
22-
cmd: cmd[0],
23-
args: cmd.slice(1),
24-
cwd: dir,
25-
stdout: "piped",
26-
});
27-
},
28-
);
25+
return execProcess({
26+
cmd: file,
27+
args: ["-y"],
28+
cwd: dir,
29+
stdout: "piped",
30+
});
2931
} else if (file.endsWith("zip")) {
3032
// It's a zip file
3133
if (isWindows) {
32-
const args = [
33-
"-Command",
34-
`"& { Add-Type -A 'System.IO.Compression.FileSystem'; [IO.Compression.ZipFile]::ExtractToDirectory('${file}', '${dir}'); }"`,
35-
];
36-
return safeWindowsExec(
37-
"powershell",
38-
args,
39-
(cmd: string[]) => {
40-
return execProcess(
41-
{
42-
cmd: cmd[0],
43-
args: cmd.slice(1),
44-
stdout: "piped",
45-
},
46-
);
47-
},
48-
);
34+
// UTF-8 output keeps non-ASCII characters in error messages readable
35+
const script = [
36+
"[Console]::OutputEncoding = [Text.Encoding]::UTF8",
37+
"Add-Type -A 'System.IO.Compression.FileSystem'",
38+
`[IO.Compression.ZipFile]::ExtractToDirectory(${
39+
powershellLiteral(file)
40+
}, ${powershellLiteral(dir)})`,
41+
].join("; ");
42+
return execProcess({
43+
cmd: "powershell",
44+
args: ["-NoProfile", "-NonInteractive", "-Command", script],
45+
stdout: "piped",
46+
});
4947
} else {
5048
// Use the built in unzip command
5149
return execProcess(

‎tests/docs/zip/unicode-entries.zip‎

173 Bytes
Binary file not shown.

‎tests/unit/zip.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* zip.test.ts
3+
*
4+
* Tests for zip extraction on Windows.
5+
* Validates fix for:
6+
* https://github.com/quarto-dev/quarto-cli/issues/15198 (enterprise .bat blocking)
7+
*
8+
* Copyright (C) 2026 Posit Software, PBC
9+
*/
10+
11+
import { unitTest } from "../test.ts";
12+
import { assert, assertEquals, assertStringIncludes } from "../asserts.ts";
13+
import { isWindows } from "../../src/deno_ral/platform.ts";
14+
import { join } from "../../src/deno_ral/path.ts";
15+
import { unzip } from "../../src/core/zip.ts";
16+
import { docs, withTempDir } from "../utils.ts";
17+
18+
// Directory name with every character that has broken Windows extraction:
19+
// space and & (cmd), ' and ’ (PowerShell string), accent and CJK (code pages)
20+
const hostileDirName = "O'Brien & D’Arcy é 日本";
21+
22+
unitTest(
23+
"unzip - extracts non-ASCII entries into a path with quote, ampersand, accent and CJK on Windows (#15198)",
24+
async () => {
25+
await withTempDir(async (tempBase) => {
26+
const dir = join(tempBase, hostileDirName);
27+
Deno.mkdirSync(dir);
28+
const archive = join(dir, "arch é.zip");
29+
Deno.copyFileSync(docs("zip/unicode-entries.zip"), archive);
30+
31+
const result = await unzip(archive);
32+
33+
assert(result.success, `unzip failed: ${result.stderr}`);
34+
assertEquals(
35+
Deno.readTextFileSync(join(dir, "café 日本", "naïve 文件.txt")),
36+
"contenu été",
37+
);
38+
});
39+
},
40+
{ ignore: !isWindows },
41+
);
42+
43+
unitTest(
44+
"unzip - failure message keeps non-ASCII path characters on Windows (#15198)",
45+
async () => {
46+
await withTempDir(async (tempBase) => {
47+
const dir = join(tempBase, hostileDirName);
48+
Deno.mkdirSync(dir);
49+
const archive = join(dir, "absent é 日本.zip");
50+
51+
const result = await unzip(archive);
52+
53+
assert(!result.success, "unzip of a missing archive should fail");
54+
assertStringIncludes(result.stderr ?? "", "absent é 日本.zip");
55+
});
56+
},
57+
{ ignore: !isWindows },
58+
);

0 commit comments

Comments
 (0)