diff --git a/src/remote.test.ts b/src/remote.test.ts new file mode 100644 index 00000000..65f4624b --- /dev/null +++ b/src/remote.test.ts @@ -0,0 +1,79 @@ +import { expect, describe, it, vi } from "vitest" +import * as vscode from "vscode" +import { Remote } from "./remote" +import type { Storage } from "./storage" +import type { Commands } from "./commands" + +// Mock vscode +const mockVscode = { + ExtensionMode: { + Production: 1, + }, + window: { + showInformationMessage: vi.fn(), + showErrorMessage: vi.fn(), + }, + workspace: { + getConfiguration: vi.fn(), + }, +} as unknown as typeof vscode + +vi.mock("vscode", () => mockVscode) + +// Mock dependencies +const storage = { + writeToCoderOutputChannel: vi.fn(), + getUserSettingsPath: vi.fn(), + getSessionTokenPath: vi.fn(), + getNetworkInfoPath: vi.fn(), +} as unknown as Storage + +const commands = { + workspace: undefined, + workspaceLogPath: undefined, +} as unknown as Commands + +describe("Windows path escaping", () => { + it("should properly escape Windows paths for SSH config", () => { + const remote = new Remote(mockVscode, storage, commands, mockVscode.ExtensionMode.Production) + + // Test basic Windows path + const path1 = "C:\\Users\\micha\\logs" + expect(remote.escapeWindowsPath(path1)).toBe('"C:/Users/micha/logs"') + + // Test path with spaces + const path2 = "C:\\Program Files\\My App\\logs" + expect(remote.escapeWindowsPath(path2)).toBe('"C:/Program Files/My App/logs"') + + // Test path with special characters + const path3 = "C:\\Users\\micha\\My Folder (v2)\\logs" + expect(remote.escapeWindowsPath(path3)).toBe('"C:/Users/micha/My Folder (v2)/logs"') + + // Test path with quotes + const path4 = 'C:\\Users\\micha\\"quoted"\\logs' + expect(remote.escapeWindowsPath(path4)).toBe('"C:/Users/micha/\\"quoted\\"/logs"') + }) + + it("should use correct escape function based on platform", () => { + const remote = new Remote(mockVscode, storage, commands, mockVscode.ExtensionMode.Production) + + // Mock platform + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + value: 'win32' + }) + + // Test Windows path + const path1 = "C:\\Users\\micha\\logs" + expect(remote.escape(path1)).toBe('"C:/Users/micha/logs"') + + // Restore platform + Object.defineProperty(process, 'platform', { + value: originalPlatform + }) + + // Test Unix path + const path2 = "/home/user/logs" + expect(remote.escape(path2)).toBe('"/home/user/logs"') + }) +}) \ No newline at end of file diff --git a/src/remote.ts b/src/remote.ts index 8e5a5eab..bbcec2c1 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -46,7 +46,7 @@ export class Remote { private readonly storage: Storage, private readonly commands: Commands, private readonly mode: vscode.ExtensionMode, - ) {} + ) { } private async confirmStart(workspaceName: string): Promise { const action = await this.vscodeProposed.window.showInformationMessage( @@ -660,7 +660,7 @@ export class Remote { return expandPath( String( vscode.workspace.getConfiguration().get("coder.proxyLogDirectory") ?? - "", + "", ).trim(), ); } @@ -680,6 +680,24 @@ export class Remote { return ` --log-dir ${escape(logDir)}`; } + /** + * Properly escapes a Windows path for use in SSH config + * This preserves backslashes and handles spaces/special characters + */ + public escapeWindowsPath(path: string): string { + // Replace backslashes with forward slashes for SSH compatibility + const normalizedPath = path.replace(/\\/g, '/') + // Escape any special characters and wrap in quotes + return `"${normalizedPath.replace(/"/g, '\\"')}"` + } + + public escape = (str: string): string => { + if (os.platform() === "win32") { + return this.escapeWindowsPath(str) + } + return `"${str.replace(/"/g, '\\"')}"` + } + // updateSSHConfig updates the SSH configuration with a wildcard that handles // all Coder entries. private async updateSSHConfig( @@ -762,6 +780,14 @@ export class Remote { const headerArgs = getHeaderArgs(vscode.workspace.getConfiguration()); const headerArgList = headerArgs.length > 0 ? ` ${headerArgs.join(" ")}` : ""; + // Escape a command line to be executed by the Coder binary, so ssh doesn't substitute variables. + const escapeSubcommand: (str: string) => string = + os.platform() === "win32" + ? // On Windows variables are %VAR%, and we need to use double quotes. + (str) => escape(str).replace(/%/g, "%%") + : // On *nix we can use single quotes to escape $VARS. + // Note single quotes cannot be escaped inside single quotes. + (str) => `'${str.replace(/'/g, "'\\''")}'` const hostPrefix = label ? `${AuthorityPrefix}.${label}--` @@ -769,13 +795,13 @@ export class Remote { const proxyCommand = featureSet.wildcardSSH ? `${escapeCommandArg(binaryPath)}${headerArgList} --global-config ${escapeCommandArg( - path.dirname(this.storage.getSessionTokenPath(label)), - )} ssh --stdio --usage-app=vscode --disable-autostart --network-info-dir ${escapeCommandArg(this.storage.getNetworkInfoPath())}${await this.formatLogArg(logDir)} --ssh-host-prefix ${hostPrefix} %h` + path.dirname(this.storage.getSessionTokenPath(label)), + )} ssh --stdio --usage-app=vscode --disable-autostart --network-info-dir ${escapeCommandArg(this.storage.getNetworkInfoPath())}${await this.formatLogArg(logDir)} --ssh-host-prefix ${hostPrefix} %h` : `${escapeCommandArg(binaryPath)}${headerArgList} vscodessh --network-info-dir ${escapeCommandArg( - this.storage.getNetworkInfoPath(), - )}${await this.formatLogArg(logDir)} --session-token-file ${escapeCommandArg(this.storage.getSessionTokenPath(label))} --url-file ${escapeCommandArg( - this.storage.getUrlPath(label), - )} %h`; + this.storage.getNetworkInfoPath(), + )}${await this.formatLogArg(logDir)} --session-token-file ${escapeCommandArg(this.storage.getSessionTokenPath(label))} --url-file ${escapeCommandArg( + this.storage.getUrlPath(label), + )} %h`; const sshValues: SSHValues = { Host: hostPrefix + `*`,