From 9bf6184dbefefb8791aa5183de793cb5c5b6a976 Mon Sep 17 00:00:00 2001 From: DuProcess <273172371+DuProcess@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:35:00 -0400 Subject: [PATCH] Quote VS Code Dosh proxy commands on Windows --- .github/workflows/ci.yml | 4 ++ tests/release_scripts.rs | 8 +++ vscode-extension/extension.js | 92 ++++++++++++++++++++++++------ vscode-extension/extension.test.js | 50 ++++++++++++++++ 4 files changed, 135 insertions(+), 19 deletions(-) create mode 100644 vscode-extension/extension.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60b1560..ed45902 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,8 @@ jobs: run: cargo build --release - name: Test macOS client run: cargo test --all-targets + - name: Test VS Code extension helpers + run: node --test vscode-extension/extension.test.js windows-client: runs-on: windows-latest @@ -90,6 +92,8 @@ jobs: run: cargo build --release --bin dosh-client --bin dosh-bench - name: Test Windows client run: cargo test --all-targets + - name: Test VS Code extension helpers + run: node --test vscode-extension/extension.test.js - name: Package Windows client shell: bash run: sh scripts/package-release.sh diff --git a/tests/release_scripts.rs b/tests/release_scripts.rs index 6f1ff17..619f38a 100644 --- a/tests/release_scripts.rs +++ b/tests/release_scripts.rs @@ -82,6 +82,10 @@ fn release_gates_test_all_targets() { macos_client.contains("run: cargo test --all-targets"), "macOS CI must run Rust tests, not only release packaging" ); + assert!( + macos_client.contains("run: node --test vscode-extension/extension.test.js"), + "macOS CI must exercise VS Code helper quoting" + ); let windows_client = ci .split("windows-client:") .nth(1) @@ -100,6 +104,10 @@ fn release_gates_test_all_targets() { windows_client.contains("run: cargo test --all-targets"), "Windows CI must run Rust tests, not only build/package" ); + assert!( + windows_client.contains("run: node --test vscode-extension/extension.test.js"), + "Windows CI must exercise VS Code helper quoting" + ); } #[test] diff --git a/vscode-extension/extension.js b/vscode-extension/extension.js index bfaf4ea..f65b5fd 100644 --- a/vscode-extension/extension.js +++ b/vscode-extension/extension.js @@ -1,22 +1,37 @@ -const vscode = require('vscode'); const fs = require('fs'); const os = require('os'); const path = require('path'); +let vscode; +try { + vscode = require('vscode'); +} catch (_) { + vscode = undefined; +} + +function requireVscode() { + if (!vscode) { + throw new Error('VS Code API is only available inside VS Code'); + } + return vscode; +} + function activate(context) { + const api = requireVscode(); context.subscriptions.push( - vscode.commands.registerCommand('dosh.openRemote', openRemote), - vscode.commands.registerCommand('dosh.configureHost', configureHostCommand), - vscode.commands.registerCommand('dosh.showSshConfig', showSshConfig) + api.commands.registerCommand('dosh.openRemote', openRemote), + api.commands.registerCommand('dosh.configureHost', configureHostCommand), + api.commands.registerCommand('dosh.showSshConfig', showSshConfig) ); } async function openRemote() { + const api = requireVscode(); const configured = await configureHost(); if (!configured) { return; } - const remotePath = await vscode.window.showInputBox({ + const remotePath = await api.window.showInputBox({ title: 'Remote path', prompt: 'Path to open on the remote host', value: '~' @@ -25,22 +40,24 @@ async function openRemote() { return; } const suffix = remotePath ? `/${remotePath.replace(/^\/+/, '')}` : ''; - await vscode.commands.executeCommand( + await api.commands.executeCommand( 'vscode.openFolder', - vscode.Uri.parse(`vscode-remote://ssh-remote+${configured.alias}${suffix}`), + api.Uri.parse(`vscode-remote://ssh-remote+${configured.alias}${suffix}`), { forceNewWindow: true } ); } async function configureHostCommand() { + const api = requireVscode(); const configured = await configureHost(); if (configured) { - vscode.window.showInformationMessage(`Dosh Remote-SSH host ready: ${configured.alias}`); + api.window.showInformationMessage(`Dosh Remote-SSH host ready: ${configured.alias}`); } } async function configureHost() { - const host = await vscode.window.showInputBox({ + const api = requireVscode(); + const host = await api.window.showInputBox({ title: 'Dosh host', prompt: 'Dosh host alias, e.g. palav', ignoreFocusOut: true @@ -48,12 +65,12 @@ async function configureHost() { if (!host) { return undefined; } - const user = await vscode.window.showInputBox({ + const user = await api.window.showInputBox({ title: 'Remote SSH user', prompt: 'Optional. Leave blank to let SSH config decide.', ignoreFocusOut: true }); - const config = vscode.workspace.getConfiguration('dosh'); + const config = api.workspace.getConfiguration('dosh'); const alias = `dosh-${safeAlias(host)}`; const block = sshBlock({ alias, @@ -62,7 +79,8 @@ async function configureHost() { executable: config.get('executable') || 'dosh', targetHost: config.get('targetHost') || '127.0.0.1', targetPort: Number(config.get('targetPort') || 22), - doshPort: Number(config.get('doshPort') || 0) + doshPort: Number(config.get('doshPort') || 0), + platform: process.platform }); const sshDir = path.join(os.homedir(), '.ssh'); fs.mkdirSync(sshDir, { recursive: true }); @@ -74,11 +92,12 @@ async function configureHost() { } function sshBlock(options) { - let proxy = shellQuote(options.executable); + const platform = options.platform || process.platform; + let proxy = sshConfigWord(options.executable, platform); if (options.doshPort > 0) { proxy += ` --dosh-port ${options.doshPort}`; } - proxy += ` proxy-stdio ${shellQuote(options.host)} %h %p`; + proxy += ` proxy-stdio ${sshConfigWord(options.host, platform)} %h %p`; const lines = [ `# BEGIN DOSH ${options.alias}`, `Host ${options.alias}`, @@ -139,15 +158,16 @@ function upsertBlock(configPath, alias, block) { } async function showSshConfig() { - const config = vscode.workspace.getConfiguration('dosh'); + const api = requireVscode(); + const config = api.workspace.getConfiguration('dosh'); const sshDir = path.join(os.homedir(), '.ssh'); const generatedPath = config.get('generatedSshConfig') || path.join(sshDir, 'config.dosh'); if (!fs.existsSync(generatedPath)) { - vscode.window.showWarningMessage('No generated Dosh SSH config yet.'); + api.window.showWarningMessage('No generated Dosh SSH config yet.'); return; } - const doc = await vscode.workspace.openTextDocument(generatedPath); - await vscode.window.showTextDocument(doc); + const doc = await api.workspace.openTextDocument(generatedPath); + await api.window.showTextDocument(doc); } function safeAlias(value) { @@ -162,9 +182,43 @@ function shellQuote(value) { return `'${value.replace(/'/g, `'\\''`)}'`; } +function sshConfigWord(value, platform = process.platform) { + if (platform === 'win32') { + return windowsCommandWord(value); + } + return shellQuote(value); +} + +function windowsCommandWord(value) { + let out = '"'; + let backslashes = 0; + for (const ch of value) { + if (ch === '\\') { + backslashes += 1; + continue; + } + if (ch === '"') { + out += '\\'.repeat(backslashes * 2 + 1); + out += '"'; + backslashes = 0; + continue; + } + out += '\\'.repeat(backslashes); + backslashes = 0; + out += ch; + } + out += '\\'.repeat(backslashes * 2); + out += '"'; + return out; +} + function deactivate() {} module.exports = { activate, - deactivate + deactivate, + sshBlock, + shellQuote, + sshConfigWord, + windowsCommandWord }; diff --git a/vscode-extension/extension.test.js b/vscode-extension/extension.test.js new file mode 100644 index 0000000..1dba3ec --- /dev/null +++ b/vscode-extension/extension.test.js @@ -0,0 +1,50 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + shellQuote, + sshBlock, + sshConfigWord, + windowsCommandWord +} = require('./extension'); + +test('posix ssh config words use shell quoting', () => { + assert.equal( + sshConfigWord('/Users/palav/My App/dosh', 'darwin'), + "'/Users/palav/My App/dosh'" + ); + assert.equal(shellQuote("say 'hi'"), "'say '\\''hi'\\'''"); +}); + +test('windows ssh config words use Windows command quoting', () => { + assert.equal( + sshConfigWord('C:\\Program Files\\Dosh\\dosh.exe', 'win32'), + '"C:\\Program Files\\Dosh\\dosh.exe"' + ); + assert.equal( + windowsCommandWord('C:\\path\\before"quote'), + '"C:\\path\\before\\"quote"' + ); + assert.equal( + windowsCommandWord('C:\\Program Files\\Dosh\\'), + '"C:\\Program Files\\Dosh\\\\"' + ); +}); + +test('ssh block quotes proxy command for the target platform', () => { + const block = sshBlock({ + alias: 'dosh-work', + host: 'work host', + executable: 'C:\\Program Files\\Dosh\\dosh.exe', + targetHost: '127.0.0.1', + targetPort: 22, + doshPort: 50000, + platform: 'win32' + }); + + assert.match( + block, + /ProxyCommand "C:\\Program Files\\Dosh\\dosh\.exe" --dosh-port 50000 proxy-stdio "work host" %h %p/ + ); + assert.doesNotMatch(block, /'C:\\Program Files\\Dosh\\dosh\.exe'/); +});