From ec79965e258be5eac0a5a146503b5bed324c1a3d Mon Sep 17 00:00:00 2001 From: Sean McManus Date: Mon, 29 Jun 2026 08:12:59 -0700 Subject: [PATCH 1/5] Fix Windows backslash paths being mangled when adding an SSH target --- Extension/src/SSH/sshCommandToConfig.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Extension/src/SSH/sshCommandToConfig.ts b/Extension/src/SSH/sshCommandToConfig.ts index e60463bd6..d204e5e18 100644 --- a/Extension/src/SSH/sshCommandToConfig.ts +++ b/Extension/src/SSH/sshCommandToConfig.ts @@ -5,6 +5,7 @@ import { BasicParser, IParsedOption } from 'posix-getopt'; import { parse } from 'shell-quote'; +import { isWindows } from '../constants'; /** * Mapping of flags to functions that add the relevant flag to the map of @@ -124,7 +125,10 @@ export class CommandParseError extends Error { } * Attempts to convert an SSH command to an SSH config entry. */ export function sshCommandToConfig(command: string, name?: string): { [key: string]: string } { - const parts: string[] = parse(command) as string[]; + // shell-quote's parse() treats '\' as a POSIX escape character and strips it, which mangles + // Windows paths (e.g. '-i C:\Users\me\key' becomes 'C:Usersmekey'). On Windows, double the + // backslashes first so parse()'s unescaping restores the original single backslashes. + const parts: string[] = parse(isWindows ? command.replace(/\\/g, '\\\\') : command) as string[]; // ignore 'ssh' if the user entered that as their first word if (parts[0] === 'ssh') { From f16281cc621f473714fa8269bf04e359360f5106 Mon Sep 17 00:00:00 2001 From: Sean McManus Date: Mon, 29 Jun 2026 16:32:12 -0700 Subject: [PATCH 2/5] Alternative fix. --- Extension/ThirdPartyNotices.txt | 34 ------------------------ Extension/package.json | 2 -- Extension/src/LanguageServer/settings.ts | 3 +-- Extension/src/SSH/sshCommandToConfig.ts | 13 +++++---- Extension/src/common.ts | 2 +- 5 files changed, 8 insertions(+), 46 deletions(-) diff --git a/Extension/ThirdPartyNotices.txt b/Extension/ThirdPartyNotices.txt index 06f108394..6c9ba6d3a 100644 --- a/Extension/ThirdPartyNotices.txt +++ b/Extension/ThirdPartyNotices.txt @@ -3241,40 +3241,6 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---------------------------------------------------------- - ---------------------------------------------------------- - -shell-quote 1.8.4 - MIT -https://github.com/ljharb/shell-quote - -Copyright (c) 2013 James Halliday (mail@substack.net) - -The MIT License - -Copyright (c) 2013 James Halliday (mail@substack.net) - -Permission is hereby granted, free of charge, -to any person obtaining a copy of this software and -associated documentation files (the "Software"), to -deal in the Software without restriction, including -without limitation the rights to use, copy, modify, -merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom -the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR -ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - --------------------------------------------------------- --------------------------------------------------------- diff --git a/Extension/package.json b/Extension/package.json index cca7c7cc5..e697ba4e2 100644 --- a/Extension/package.json +++ b/Extension/package.json @@ -6870,7 +6870,6 @@ "@types/plist": "^3.0.5", "@types/proxyquire": "^1.3.31", "@types/semver": "^7.5.8", - "@types/shell-quote": "^1.7.5", "@types/sinon": "^21.0.0", "@types/tmp": "^0.2.6", "@types/which": "^2.0.2", @@ -6926,7 +6925,6 @@ "node-vswhere": "^1.0.2", "plist": "^3.1.0", "posix-getopt": "^1.2.1", - "shell-quote": "1.8.4", "ssh-config": "^4.4.4", "tmp": "^0.2.7", "vscode-cpptools": "^7.1.1", diff --git a/Extension/src/LanguageServer/settings.ts b/Extension/src/LanguageServer/settings.ts index e3e02e6e5..0eb74fd06 100644 --- a/Extension/src/LanguageServer/settings.ts +++ b/Extension/src/LanguageServer/settings.ts @@ -8,7 +8,6 @@ import { execSync } from 'child_process'; import * as os from 'os'; import * as path from 'path'; import * as semver from 'semver'; -import { quote } from 'shell-quote'; import * as vscode from 'vscode'; import * as nls from 'vscode-nls'; import * as which from 'which'; @@ -297,7 +296,7 @@ export class CppSettings extends Settings { let bundledVersion: string; try { const bundledPath: string = getExtensionFilePath(`./LLVM/bin/${clangName}`); - const output: string = execSync(quote([bundledPath, '--version'])).toString(); + const output: string = execSync(`"${bundledPath}" --version`).toString(); bundledVersion = output.match(/(\d+\.\d+\.\d+)/)?.[1] ?? ""; if (!semver.valid(bundledVersion)) { return path; diff --git a/Extension/src/SSH/sshCommandToConfig.ts b/Extension/src/SSH/sshCommandToConfig.ts index d204e5e18..aa916a6df 100644 --- a/Extension/src/SSH/sshCommandToConfig.ts +++ b/Extension/src/SSH/sshCommandToConfig.ts @@ -4,8 +4,7 @@ * ------------------------------------------------------------------------------------------ */ import { BasicParser, IParsedOption } from 'posix-getopt'; -import { parse } from 'shell-quote'; -import { isWindows } from '../constants'; +import { extractArgs } from '../common'; /** * Mapping of flags to functions that add the relevant flag to the map of @@ -125,10 +124,10 @@ export class CommandParseError extends Error { } * Attempts to convert an SSH command to an SSH config entry. */ export function sshCommandToConfig(command: string, name?: string): { [key: string]: string } { - // shell-quote's parse() treats '\' as a POSIX escape character and strips it, which mangles - // Windows paths (e.g. '-i C:\Users\me\key' becomes 'C:Usersmekey'). On Windows, double the - // backslashes first so parse()'s unescaping restores the original single backslashes. - const parts: string[] = parse(isWindows ? command.replace(/\\/g, '\\\\') : command) as string[]; + // Parse the command line into arguments using the same platform-correct logic we use + // everywhere else (MSVC-style quoting on Windows, wordexp elsewhere). This correctly + // preserves Windows paths and quoted segments. + const parts: string[] = extractArgs(command); // ignore 'ssh' if the user entered that as their first word if (parts[0] === 'ssh') { @@ -222,7 +221,7 @@ function parseFlags(input: string[], entries: { [key: string]: string }): number * are not mentioned on the ssh(1) man page and don't seem to have use in the * wild. In the OpenSSH source, they appear to be ignored[3]. * - * The `shell-quote` library, like libc does for OpenSSH, takes care of dealing + * The `extractArgs` command-line parser, like libc does for OpenSSH, takes care of dealing * with quotations for for us. * * 1. https://github.com/openssh/openssh-portable/blob/e3b6c966b79c3ea5d51b923c3bbdc41e13b96ea0/ssh.c#L999 diff --git a/Extension/src/common.ts b/Extension/src/common.ts index 7bfda0ec8..1c5a16984 100644 --- a/Extension/src/common.ts +++ b/Extension/src/common.ts @@ -1006,7 +1006,7 @@ function legacyExtractArgs(argsString: string): string[] { return result; } -function extractArgs(argsString: string): string[] { +export function extractArgs(argsString: string): string[] { argsString = argsString.trim(); if (os.platform() === 'win32') { argsString = resolveWindowsEnvironmentVariables(argsString); From 7685554c768f0741450650f68515bd1f503c1e85 Mon Sep 17 00:00:00 2001 From: Sean McManus Date: Mon, 29 Jun 2026 16:47:32 -0700 Subject: [PATCH 3/5] Alternate fix. --- Extension/src/SSH/sshCommandToConfig.ts | 57 ++++++++++++++++++++++--- Extension/src/common.ts | 2 +- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/Extension/src/SSH/sshCommandToConfig.ts b/Extension/src/SSH/sshCommandToConfig.ts index aa916a6df..bf6c5a4bc 100644 --- a/Extension/src/SSH/sshCommandToConfig.ts +++ b/Extension/src/SSH/sshCommandToConfig.ts @@ -4,7 +4,6 @@ * ------------------------------------------------------------------------------------------ */ import { BasicParser, IParsedOption } from 'posix-getopt'; -import { extractArgs } from '../common'; /** * Mapping of flags to functions that add the relevant flag to the map of @@ -124,10 +123,10 @@ export class CommandParseError extends Error { } * Attempts to convert an SSH command to an SSH config entry. */ export function sshCommandToConfig(command: string, name?: string): { [key: string]: string } { - // Parse the command line into arguments using the same platform-correct logic we use - // everywhere else (MSVC-style quoting on Windows, wordexp elsewhere). This correctly - // preserves Windows paths and quoted segments. - const parts: string[] = extractArgs(command); + // Split the command line into arguments. We deliberately use shell-like tokenization that + // strips single and double quotes but treats backslashes as literal characters, so Windows + // paths (e.g. -i C:\Users\me\key) and quoted paths with spaces are both preserved. + const parts: string[] = splitArgs(command); // ignore 'ssh' if the user entered that as their first word if (parts[0] === 'ssh') { @@ -170,6 +169,50 @@ export function sshCommandToConfig(command: string, name?: string): { [key: stri return { Host, HostName, ...options }; } +/** + * Splits a command line into arguments using shell-like tokenization that behaves + * consistently across platforms. Both single and double quotes group their contents + * and are removed, unquoted whitespace separates arguments, and backslashes are kept + * as literal characters so Windows paths such as `C:\Users\me\key` are preserved + * rather than being consumed as escape sequences. An unterminated quote simply runs + * to the end of the string (matching the lenient behavior of a shell command line). + */ +function splitArgs(command: string): string[] { + const args: string[] = []; + let current: string = ''; + let inToken: boolean = false; + let quoteChar: string | undefined; + for (const c of command) { + if (quoteChar !== undefined) { + if (c === quoteChar) { + quoteChar = undefined; + } else { + current += c; + } + continue; + } + if (c === '"' || c === '\'') { + quoteChar = c; + inToken = true; + continue; + } + if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { + if (inToken) { + args.push(current); + current = ''; + inToken = false; + } + continue; + } + current += c; + inToken = true; + } + if (inToken) { + args.push(current); + } + return args; +} + /** * Parses flags from the given array of arguments, returning the index of the * next non-flag in the input (or the total length of the input if none are found). @@ -221,8 +264,8 @@ function parseFlags(input: string[], entries: { [key: string]: string }): number * are not mentioned on the ssh(1) man page and don't seem to have use in the * wild. In the OpenSSH source, they appear to be ignored[3]. * - * The `extractArgs` command-line parser, like libc does for OpenSSH, takes care of dealing - * with quotations for for us. + * The command-line tokenizer, like libc does for OpenSSH, takes care of dealing + * with quotations for us. * * 1. https://github.com/openssh/openssh-portable/blob/e3b6c966b79c3ea5d51b923c3bbdc41e13b96ea0/ssh.c#L999 * 2. https://tools.ietf.org/html/draft-ietf-secsh-scp-sftp-ssh-uri-04#section-3.3 diff --git a/Extension/src/common.ts b/Extension/src/common.ts index 1c5a16984..7bfda0ec8 100644 --- a/Extension/src/common.ts +++ b/Extension/src/common.ts @@ -1006,7 +1006,7 @@ function legacyExtractArgs(argsString: string): string[] { return result; } -export function extractArgs(argsString: string): string[] { +function extractArgs(argsString: string): string[] { argsString = argsString.trim(); if (os.platform() === 'win32') { argsString = resolveWindowsEnvironmentVariables(argsString); From a8aa3b6d296b2236f24812f5f36c775ad899dafb Mon Sep 17 00:00:00 2001 From: Sean McManus Date: Mon, 29 Jun 2026 17:04:43 -0700 Subject: [PATCH 4/5] Fixes. Unit tests. --- Extension/src/SSH/sshCommandToConfig.ts | 37 ++++++-- .../test/unit/sshCommandToConfig.test.ts | 95 +++++++++++++++++++ 2 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 Extension/test/unit/sshCommandToConfig.test.ts diff --git a/Extension/src/SSH/sshCommandToConfig.ts b/Extension/src/SSH/sshCommandToConfig.ts index bf6c5a4bc..e41b43b6e 100644 --- a/Extension/src/SSH/sshCommandToConfig.ts +++ b/Extension/src/SSH/sshCommandToConfig.ts @@ -124,8 +124,9 @@ export class CommandParseError extends Error { } */ export function sshCommandToConfig(command: string, name?: string): { [key: string]: string } { // Split the command line into arguments. We deliberately use shell-like tokenization that - // strips single and double quotes but treats backslashes as literal characters, so Windows - // paths (e.g. -i C:\Users\me\key) and quoted paths with spaces are both preserved. + // strips single and double quotes and lets an unquoted backslash escape a following space, + // while keeping backslashes before other characters literal, so both Unix paths with escaped + // spaces (e.g. /home/me/my\ key) and Windows paths (e.g. C:\Users\me\key) are preserved. const parts: string[] = splitArgs(command); // ignore 'ssh' if the user entered that as their first word @@ -172,17 +173,23 @@ export function sshCommandToConfig(command: string, name?: string): { [key: stri /** * Splits a command line into arguments using shell-like tokenization that behaves * consistently across platforms. Both single and double quotes group their contents - * and are removed, unquoted whitespace separates arguments, and backslashes are kept - * as literal characters so Windows paths such as `C:\Users\me\key` are preserved - * rather than being consumed as escape sequences. An unterminated quote simply runs - * to the end of the string (matching the lenient behavior of a shell command line). + * and are removed, and unquoted whitespace separates arguments. + * + * Outside of quotes, a backslash escapes only a following whitespace character (so a + * Unix path such as `/home/me/my\ key` keeps its space as a single argument). Before + * any other character a backslash is kept literal, so Windows paths such as + * `C:\Users\me\key` are preserved rather than being consumed as escape sequences. + * + * An unterminated quote simply runs to the end of the string (matching the lenient + * behavior of a shell command line). */ -function splitArgs(command: string): string[] { +export function splitArgs(command: string): string[] { const args: string[] = []; let current: string = ''; let inToken: boolean = false; let quoteChar: string | undefined; - for (const c of command) { + for (let i: number = 0; i < command.length; i++) { + const c: string = command[i]; if (quoteChar !== undefined) { if (c === quoteChar) { quoteChar = undefined; @@ -196,6 +203,20 @@ function splitArgs(command: string): string[] { inToken = true; continue; } + if (c === '\\') { + const next: string | undefined = command[i + 1]; + // Only escape a following whitespace character; otherwise keep the backslash + // literal so Windows path separators survive. + if (next === ' ' || next === '\t' || next === '\r' || next === '\n') { + current += next; + inToken = true; + i++; + continue; + } + current += c; + inToken = true; + continue; + } if (c === ' ' || c === '\t' || c === '\r' || c === '\n') { if (inToken) { args.push(current); diff --git a/Extension/test/unit/sshCommandToConfig.test.ts b/Extension/test/unit/sshCommandToConfig.test.ts new file mode 100644 index 000000000..81e84a321 --- /dev/null +++ b/Extension/test/unit/sshCommandToConfig.test.ts @@ -0,0 +1,95 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. + * See 'LICENSE' in the project root for license information. + * ------------------------------------------------------------------------------------------ */ + +import { deepStrictEqual, strictEqual } from 'assert'; +import { describe, it } from 'mocha'; +import { splitArgs, sshCommandToConfig } from '../../src/SSH/sshCommandToConfig'; + +// eslint-disable-next-line import/no-unassigned-import +require('source-map-support/register'); + +describe('splitArgs', () => { + // [description, input, expected tokens] + const cases: [string, string, string[]][] = [ + ['empty string', '', []], + ['whitespace only', ' \t ', []], + ['simple words', 'ssh user@host', ['ssh', 'user@host']], + ['collapses runs of whitespace', 'ssh \t user@host', ['ssh', 'user@host']], + ['trims leading/trailing whitespace', ' ssh user@host ', ['ssh', 'user@host']], + + // Windows paths: backslashes must stay literal (the original bug). + ['bare Windows path', 'ssh -i C:\\Users\\me\\key user@host', ['ssh', '-i', 'C:\\Users\\me\\key', 'user@host']], + ['double-quoted Windows path with spaces', 'ssh -i "C:\\Program Files\\me\\key" user@host', ['ssh', '-i', 'C:\\Program Files\\me\\key', 'user@host']], + ['single-quoted Windows path with spaces', "ssh -i 'C:\\Program Files\\me\\key' user@host", ['ssh', '-i', 'C:\\Program Files\\me\\key', 'user@host']], + ['single-quoted Windows path without spaces', "ssh -i 'C:\\Users\\me\\key' user@host", ['ssh', '-i', 'C:\\Users\\me\\key', 'user@host']], + + // Quote handling. + ['strips double quotes', '"a b" c', ['a b', 'c']], + ['strips single quotes', "'a b' c", ['a b', 'c']], + ['quotes joined to adjacent text', 'a"b c"d', ['ab cd']], + ['single quotes inside double quotes are literal', '"it\'s here"', ["it's here"]], + ['double quotes inside single quotes are literal', "'say \"hi\"'", ['say "hi"']], + ['empty double-quoted token is preserved', 'a "" b', ['a', '', 'b']], + ['empty single-quoted token is preserved', "a '' b", ['a', '', 'b']], + + // Forward-slash (POSIX-style) paths are unaffected. + ['forward-slash path', 'ssh -i /home/me/.ssh/id_rsa user@host', ['ssh', '-i', '/home/me/.ssh/id_rsa', 'user@host']], + + // An unquoted backslash escapes a following whitespace character (POSIX behavior), so a + // Unix path with an escaped space stays a single argument. + ['backslash escapes a space', 'ssh -i /home/me/my\\ key user@host', ['ssh', '-i', '/home/me/my key', 'user@host']], + ['backslash escapes multiple spaces', 'ssh -i /home/me/key\\ with\\ spaces user@host', ['ssh', '-i', '/home/me/key with spaces', 'user@host']], + ['backslash escapes a tab', 'a\\\tb', ['a\tb']], + ['trailing backslash is literal', 'foo\\', ['foo\\']], + ['backslash before a letter stays literal (Windows path)', 'C:\\Users\\me', ['C:\\Users\\me']], + ['UNC path keeps doubled backslashes', '\\\\server\\share', ['\\\\server\\share']], + // A backslash that ends a quoted segment is literal and must not escape the following + // separator (only an unquoted backslash directly before whitespace escapes). + ['quoted path ending in backslash is not joined to the next arg', '"C:\\Program Files\\" next', ['C:\\Program Files\\', 'next']], + + // Lenient handling of an unterminated quote: runs to end of string. + ['unterminated double quote runs to end', 'ssh -i "C:\\Users\\me', ['ssh', '-i', 'C:\\Users\\me']], + ['unterminated single quote runs to end', "ssh -i 'C:\\Users\\me", ['ssh', '-i', 'C:\\Users\\me']] + ]; + + for (const [description, input, expected] of cases) { + it(`${description}: ${JSON.stringify(input)}`, () => { + deepStrictEqual(splitArgs(input), expected); + }); + } +}); + +describe('sshCommandToConfig', () => { + it('preserves a bare Windows identity-file path', () => { + const config = sshCommandToConfig('ssh -i C:\\Users\\me\\.ssh\\id_rsa user@host'); + strictEqual(config.IdentityFile, 'C:\\Users\\me\\.ssh\\id_rsa'); + strictEqual(config.HostName, 'host'); + strictEqual(config.User, 'user'); + }); + + it('preserves a single-quoted Windows identity-file path with spaces', () => { + const config = sshCommandToConfig("ssh -i 'C:\\Program Files\\me\\key' user@host"); + strictEqual(config.IdentityFile, 'C:\\Program Files\\me\\key'); + }); + + it('preserves a double-quoted Windows identity-file path with spaces', () => { + const config = sshCommandToConfig('ssh -i "C:\\Program Files\\me\\key" user@host'); + strictEqual(config.IdentityFile, 'C:\\Program Files\\me\\key'); + }); + + it('preserves a Unix identity-file path with a backslash-escaped space', () => { + const config = sshCommandToConfig('ssh -i /home/me/my\\ key user@host'); + strictEqual(config.IdentityFile, '/home/me/my key'); + strictEqual(config.HostName, 'host'); + strictEqual(config.User, 'user'); + }); + + it('parses host, user, and port from the connection string', () => { + const config = sshCommandToConfig('ssh -p 2222 user@host'); + strictEqual(config.HostName, 'host'); + strictEqual(config.User, 'user'); + strictEqual(config.Port, '2222'); + }); +}); From fa626523a3051e28ceab5610a93895b3d427713b Mon Sep 17 00:00:00 2001 From: Sean McManus Date: Mon, 29 Jun 2026 17:12:52 -0700 Subject: [PATCH 5/5] Copilot review fixes. --- Extension/src/SSH/sshCommandToConfig.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Extension/src/SSH/sshCommandToConfig.ts b/Extension/src/SSH/sshCommandToConfig.ts index e41b43b6e..ce5bfc0e0 100644 --- a/Extension/src/SSH/sshCommandToConfig.ts +++ b/Extension/src/SSH/sshCommandToConfig.ts @@ -180,8 +180,8 @@ export function sshCommandToConfig(command: string, name?: string): { [key: stri * any other character a backslash is kept literal, so Windows paths such as * `C:\Users\me\key` are preserved rather than being consumed as escape sequences. * - * An unterminated quote simply runs to the end of the string (matching the lenient - * behavior of a shell command line). + * This tokenizer is intentionally lenient for a single-line input box: an unterminated + * quote is not treated as an error but simply runs to the end of the string. */ export function splitArgs(command: string): string[] { const args: string[] = []; @@ -285,8 +285,8 @@ function parseFlags(input: string[], entries: { [key: string]: string }): number * are not mentioned on the ssh(1) man page and don't seem to have use in the * wild. In the OpenSSH source, they appear to be ignored[3]. * - * The command-line tokenizer, like libc does for OpenSSH, takes care of dealing - * with quotations for us. + * The `splitArgs` tokenizer has already stripped any surrounding quotes before this + * function sees a token, so it only has to deal with the unquoted connection string. * * 1. https://github.com/openssh/openssh-portable/blob/e3b6c966b79c3ea5d51b923c3bbdc41e13b96ea0/ssh.c#L999 * 2. https://tools.ietf.org/html/draft-ietf-secsh-scp-sftp-ssh-uri-04#section-3.3