diff --git a/packages/cli/src/open.ts b/packages/cli/src/open.ts index 7d8fdf5..c5d1ff7 100644 --- a/packages/cli/src/open.ts +++ b/packages/cli/src/open.ts @@ -1,10 +1,17 @@ import { spawn } from 'node:child_process'; -/** The command that opens a file in the default browser on `platform`. */ -export function browserCommand(file: string, platform: NodeJS.Platform = process.platform): { cmd: string; args: string[] } { +/** The command that opens a file in the default browser on `platform`, or why it can't be done safely. */ +export function browserCommand( + file: string, + platform: NodeJS.Platform = process.platform, +): { cmd: string; args: string[] } | { refused: string } { if (platform === 'darwin') return { cmd: 'open', args: [file] }; // `start` is a cmd builtin, and its first quoted argument is the window title. - if (platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', '""', file] }; + // Quoted, cmd takes & | < > ^ ( ) literally; it still expands %VAR% inside quotes, and " would end the quoting. + if (platform === 'win32') + return /["%]/.test(file) + ? { refused: 'the path has a " or % that cmd would interpret' } + : { cmd: 'cmd', args: ['/c', 'start', '""', `"${file}"`] }; return { cmd: 'xdg-open', args: [file] }; } @@ -13,7 +20,9 @@ export function browserCommand(file: string, platform: NodeJS.Platform = process * opener installed, a headless machine). An opener that is still running after a moment is taken as success. */ export function openInBrowser(file: string): Promise { - const { cmd, args } = browserCommand(file); + const command = browserCommand(file); + if ('refused' in command) return Promise.resolve(command.refused); + const { cmd, args } = command; return new Promise((resolve) => { const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsVerbatimArguments: cmd === 'cmd' }); const timer = setTimeout(() => resolve(null), 3000); diff --git a/packages/cli/test/open.test.ts b/packages/cli/test/open.test.ts index a1ccec0..0f51633 100644 --- a/packages/cli/test/open.test.ts +++ b/packages/cli/test/open.test.ts @@ -2,9 +2,24 @@ import { describe, expect, it } from 'vitest'; import { browserCommand } from '../src/open'; describe('browserCommand', () => { - it("uses each platform's own opener", () => { + it('uses the native opener on darwin and linux', () => { expect(browserCommand('/tmp/a b.html', 'darwin')).toEqual({ cmd: 'open', args: ['/tmp/a b.html'] }); - expect(browserCommand('C:\\d\\a.html', 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', 'C:\\d\\a.html'] }); expect(browserCommand('/tmp/a.html', 'linux')).toEqual({ cmd: 'xdg-open', args: ['/tmp/a.html'] }); }); + + it('quotes the path for cmd on win32', () => { + expect(browserCommand('C:\\d\\a.html', 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', '"C:\\d\\a.html"'] }); + }); + + it('keeps spaces and cmd metacharacters inside the quotes', () => { + const file = 'C:\\Users\\First Last\\a&b (1).html'; + expect(browserCommand(file, 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', `"${file}"`] }); + }); + + it('refuses win32 paths with a % or " that cmd would interpret', () => { + for (const file of ['C:\\d\\100%.html', 'C:\\d\\a"b.html']) { + const result = browserCommand(file, 'win32'); + expect('refused' in result && typeof result.refused).toBe('string'); + } + }); });