diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index e1b823c6e9..d59f97b3e6 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -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. diff --git a/src/core/zip.ts b/src/core/zip.ts index 8ed23ddde5..0b18dd011f 100644 --- a/src/core/zip.ts +++ b/src/core/zip.ts @@ -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( diff --git a/tests/docs/zip/unicode-entries.zip b/tests/docs/zip/unicode-entries.zip new file mode 100644 index 0000000000..a68c6a4dd0 Binary files /dev/null and b/tests/docs/zip/unicode-entries.zip differ diff --git a/tests/unit/zip.test.ts b/tests/unit/zip.test.ts new file mode 100644 index 0000000000..22a794bac4 --- /dev/null +++ b/tests/unit/zip.test.ts @@ -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); + const archive = join(dir, "arch é.zip"); + Deno.copyFileSync(docs("zip/unicode-entries.zip"), archive); + + const result = await unzip(archive); + + assert(result.success, `unzip failed: ${result.stderr}`); + assertEquals( + Deno.readTextFileSync(join(dir, "café 日本", "naïve 文件.txt")), + "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 }, +);