From 41751c469217ef83c16e8cfa1e9085cda10e0ab6 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Tue, 27 Oct 2026 11:18:33 +0100 Subject: [PATCH] Unzip on Windows without a temporary .bat file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, unzip() launched PowerShell (for .zip files) and the self-extracting TinyTeX .exe through safeWindowsExec, which writes a quarto-safe-exec*.bat into %TEMP% and runs it with cmd /c. Group Policies that block running .bat files from %TEMP% make every extraction fail: quarto add, quarto use template, quarto use brand, and the TinyTeX, veraPDF and Chrome for Testing installs (#15198). This has been the case since 2022, so no released version works on such machines; the dart-sass fix for #14367 did not cover unzip. Both programs are real executables, so the .bat added nothing here. safeWindowsExec exists for batch files and cmd quoting; unzip went through it for #2998 (spaces in the profile path), which was actually fixed by quoting the paths inside the PowerShell script. PowerShell is now launched directly with -NoProfile -NonInteractive, and arguments travel as UTF-16, so accented and CJK paths need no chcp. Paths are embedded as PowerShell single-quoted literals with every single-quote character doubled, including the curly forms PowerShell also accepts. Before, a path containing an apostrophe (O'Brien, or D’Arcy) broke the script; that is the failing case the new test captures, since the Group Policy itself cannot be reproduced locally. Windows System32 tar.exe was rejected: probing showed it fails on archive paths outside the ANSI code page and on UTF-8 entry names. The script sets [Console]::OutputEncoding to UTF-8, taking over the output side of the removed chcp 65001. Measured from a code page 850 console, without it a non-ASCII path in PowerShell's error message comes back as '?'. Like chcp, it leaves the shared console at 65001. The unit test cannot detect its removal: the test process's console is already at 65001 when it runs. --- news/changelog-1.11.md | 1 + src/core/zip.ts | 58 ++++++++++++++--------------- tests/docs/zip/unicode-entries.zip | Bin 0 -> 173 bytes tests/unit/zip.test.ts | 58 +++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 30 deletions(-) create mode 100644 tests/docs/zip/unicode-entries.zip create mode 100644 tests/unit/zip.test.ts diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index e1b823c6e9e..d59f97b3e62 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 8ed23ddde57..0b18dd011fc 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 0000000000000000000000000000000000000000..a68c6a4dd0f6b2be94b502d09b9594a7001b8427 GIT binary patch literal 173 zcmWIWW@Zs#VBp|jI2aKfyR1j>Gd}|Z11}KE0daC-+ToQ7&!#VZHfN1~UgF{PWvL3! zrnNuWy-lyAqQv{$d2P?L+MyPEHa+>^#Sq}l$Rxsm+c2PcNC3al0p6@^AjOP8=mVs^ G!8!q!Ofz`^ literal 0 HcmV?d00001 diff --git a/tests/unit/zip.test.ts b/tests/unit/zip.test.ts new file mode 100644 index 00000000000..22a794bac47 --- /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 }, +);