From 5d18d0bdc9c28f84ce2b4632747f7d1fdb9b94be Mon Sep 17 00:00:00 2001 From: chenxin Date: Wed, 14 Oct 2026 16:23:07 +0800 Subject: [PATCH] fix: quote PowerShell paths containing special characters PowerShell path suggestions were only quoted when they contained a space, and embedded single quotes were escaped as \' (bash style). Inserting a folder named `a$b`, `a&b` or `it's` therefore produced a command that PowerShell parses differently (variable expansion, call operator, unterminated string). For pwsh and powershell, wrap the path in single quotes (literal strings) when it contains whitespace or a character that is special in argument mode (' " ` $ & | ; , ( ) { } < >, or a leading @ or #), and write embedded single quotes as two single quotes. Other shells are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/runtime/utils.ts | 14 +++++++--- src/tests/runtime/utils.test.ts | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 src/tests/runtime/utils.test.ts diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index dacff16d..b5a2172c 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -42,13 +42,21 @@ type QuoteChar = `"` | `'` | "`"; const isQuoted = (value: string | undefined, quoteChar: QuoteChar): boolean => (value?.startsWith(quoteChar) && value?.endsWith(quoteChar)) ?? false; -const quoteString = (value: string, quoteChar: QuoteChar): string => { +const isPowerShell = (shell: Shell): boolean => shell == Shell.Pwsh || shell == Shell.Powershell; + +// characters that PowerShell interprets in argument mode; '@' and '#' only matter at the start of an argument +const pwshSpecialCharacters = /[\s'"`$&|;,(){}<>]|^[@#]/; + +const quoteString = (value: string, quoteChar: QuoteChar, shell: Shell): string => { if (isQuoted(value, quoteChar)) return value; + // PowerShell single-quoted strings are literal; an embedded single quote is written as two single quotes + if (isPowerShell(shell)) return `'${value.replaceAll("'", "''")}'`; const escapedValue = value.replaceAll(`\\${quoteChar}`, quoteChar).replaceAll(quoteChar, `\\${quoteChar}`); return `${quoteChar}${escapedValue}${quoteChar}`; }; -const needsQuoted = (value: string, quoteChar: QuoteChar): boolean => isQuoted(value, quoteChar) || value.includes(" "); +const needsQuoted = (value: string, quoteChar: QuoteChar, shell: Shell): boolean => + isQuoted(value, quoteChar) || (isPowerShell(shell) ? pwshSpecialCharacters.test(value) : value.includes(" ")); const getShellQuoteChar = (shell: Shell): QuoteChar => { switch (shell) { @@ -85,7 +93,7 @@ export const getShellWhitespaceEscapeChar = (shell: Shell): string => { }; export const escapePath = (value: string | undefined, shell: Shell): string | undefined => - value != null && needsQuoted(value, getShellQuoteChar(shell)) ? quoteString(value, getShellQuoteChar(shell)) : value; + value != null && needsQuoted(value, getShellQuoteChar(shell), shell) ? quoteString(value, getShellQuoteChar(shell), shell) : value; export const buildExecuteShellCommand = (timeout: number, signal?: AbortSignal): Fig.ExecuteCommandFunction => diff --git a/src/tests/runtime/utils.test.ts b/src/tests/runtime/utils.test.ts new file mode 100644 index 00000000..35cb9006 --- /dev/null +++ b/src/tests/runtime/utils.test.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { escapePath } from "../../runtime/utils.js"; +import { Shell } from "../../utils/shell.js"; + +describe("escapePath", () => { + test("bash quotes paths containing spaces", () => { + expect(escapePath("My Docs/", Shell.Bash)).toBe('"My Docs/"'); + }); + + test("bash leaves plain paths unchanged", () => { + expect(escapePath("src/", Shell.Bash)).toBe("src/"); + }); + + describe.each([Shell.Pwsh, Shell.Powershell])("%s", (shell) => { + test("leaves plain paths unchanged", () => { + expect(escapePath("src/", shell)).toBe("src/"); + expect(escapePath("a@b/", shell)).toBe("a@b/"); + }); + + test.each([ + ["My Docs/", "'My Docs/'"], + ["a$b/", "'a$b/'"], + ["a&b/", "'a&b/'"], + ["a`b/", "'a`b/'"], + ["a;b/", "'a;b/'"], + ["a,b/", "'a,b/'"], + ["a(b)/", "'a(b)/'"], + ["@a/", "'@a/'"], + ["#a/", "'#a/'"], + ])("single-quotes %s", (value, expected) => { + expect(escapePath(value, shell)).toBe(expected); + }); + + test("doubles embedded single quotes", () => { + expect(escapePath("it's/", shell)).toBe("'it''s/'"); + expect(escapePath("it's dir/", shell)).toBe("'it''s dir/'"); + }); + + test("keeps already quoted paths", () => { + expect(escapePath("'My Docs/'", shell)).toBe("'My Docs/'"); + }); + }); +});