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
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,4 @@ All changes included in 1.11:
- ([#15126](https://github.com/quarto-dev/quarto-cli/issues/15126)): Translate the `revealjs-menu-button-label` language key in French. (author: @D3ller)
- ([#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.
- ([#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`.
- ([#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.
58 changes: 28 additions & 30 deletions src/core/zip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,45 +7,43 @@ import { dirname } from "../deno_ral/path.ts";
import { existsSync } from "../deno_ral/fs.ts";
import { isWindows } from "../deno_ral/platform.ts";
import { execProcess } from "./process.ts";
import { safeWindowsExec } from "./windows.ts";

// Quote a value as a PowerShell single-quoted string literal. PowerShell
// treats ' and the curly quotes ‘ ’ ‚ ‛ all as single quotes; each is
// escaped by doubling it.
function powershellLiteral(value: string) {
return `'${value.replace(/['‘’‚‛]/g, "$&$&")}'`;
}

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

// Windows extraction launches executables directly, without a temporary
// .bat: Group Policy can block running .bat files from %TEMP% (#15198)
if (isWindows && file.endsWith(".exe")) {
// Self-extracting 7z archive (e.g., TinyTeX-windows.exe)
return safeWindowsExec(
file,
["-y"],
(cmd: string[]) => {
return execProcess({
cmd: cmd[0],
args: cmd.slice(1),
cwd: dir,
stdout: "piped",
});
},
);
return execProcess({
cmd: file,
args: ["-y"],
cwd: dir,
stdout: "piped",
});
} else if (file.endsWith("zip")) {
// It's a zip file
if (isWindows) {
const args = [
"-Command",
`"& { Add-Type -A 'System.IO.Compression.FileSystem'; [IO.Compression.ZipFile]::ExtractToDirectory('${file}', '${dir}'); }"`,
];
return safeWindowsExec(
"powershell",
args,
(cmd: string[]) => {
return execProcess(
{
cmd: cmd[0],
args: cmd.slice(1),
stdout: "piped",
},
);
},
);
// UTF-8 output keeps non-ASCII characters in error messages readable
const script = [
"[Console]::OutputEncoding = [Text.Encoding]::UTF8",
"Add-Type -A 'System.IO.Compression.FileSystem'",
`[IO.Compression.ZipFile]::ExtractToDirectory(${
powershellLiteral(file)
}, ${powershellLiteral(dir)})`,
].join("; ");
return execProcess({
cmd: "powershell",
args: ["-NoProfile", "-NonInteractive", "-Command", script],
stdout: "piped",
});
} else {
// Use the built in unzip command
return execProcess(
Expand Down
Binary file added tests/docs/zip/unicode-entries.zip
Binary file not shown.
58 changes: 58 additions & 0 deletions tests/unit/zip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* zip.test.ts
*
* Tests for zip extraction on Windows.
* Validates fix for:
* https://github.com/quarto-dev/quarto-cli/issues/15198 (enterprise .bat blocking)
*
* Copyright (C) 2026 Posit Software, PBC
*/

import { unitTest } from "../test.ts";
import { assert, assertEquals, assertStringIncludes } from "../asserts.ts";
import { isWindows } from "../../src/deno_ral/platform.ts";
import { join } from "../../src/deno_ral/path.ts";
import { unzip } from "../../src/core/zip.ts";
import { docs, withTempDir } from "../utils.ts";

// Directory name with every character that has broken Windows extraction:
// space and & (cmd), ' and ’ (PowerShell string), accent and CJK (code pages)
const hostileDirName = "O'Brien & D’Arcy é 日本";

unitTest(
"unzip - extracts non-ASCII entries into a path with quote, ampersand, accent and CJK on Windows (#15198)",
async () => {
await withTempDir(async (tempBase) => {
const dir = join(tempBase, hostileDirName);
Deno.mkdirSync(dir);

Check warning on line 27 in tests/unit/zip.test.ts

View workflow job for this annotation

GitHub Actions / runtime-boundary

tests: +2 Deno.mkdirSync in tests/unit/zip.test.ts; src/deno_ral has no replacement yet, so nothing to change in this PR. A maintainer accepts the new count before merging (see the job summary).
const archive = join(dir, "arch é.zip");
Deno.copyFileSync(docs("zip/unicode-entries.zip"), archive);

Check warning on line 29 in tests/unit/zip.test.ts

View workflow job for this annotation

GitHub Actions / runtime-boundary

tests: +1 Deno.copyFileSync in tests/unit/zip.test.ts; src/deno_ral has no replacement yet, so nothing to change in this PR. A maintainer accepts the new count before merging (see the job summary).

const result = await unzip(archive);

assert(result.success, `unzip failed: ${result.stderr}`);
assertEquals(
Deno.readTextFileSync(join(dir, "café 日本", "naïve 文件.txt")),

Check warning on line 35 in tests/unit/zip.test.ts

View workflow job for this annotation

GitHub Actions / runtime-boundary

tests: +1 Deno.readTextFileSync in tests/unit/zip.test.ts; src/deno_ral has no replacement yet, so nothing to change in this PR. A maintainer accepts the new count before merging (see the job summary).
"contenu été",
);
});
},
{ ignore: !isWindows },
);

unitTest(
"unzip - failure message keeps non-ASCII path characters on Windows (#15198)",
async () => {
await withTempDir(async (tempBase) => {
const dir = join(tempBase, hostileDirName);
Deno.mkdirSync(dir);
const archive = join(dir, "absent é 日本.zip");

const result = await unzip(archive);

assert(!result.success, "unzip of a missing archive should fail");
assertStringIncludes(result.stderr ?? "", "absent é 日本.zip");
});
},
{ ignore: !isWindows },
);
Loading