diff --git a/src/shared/browser-url.test.ts b/src/shared/browser-url.test.ts index 3e978d48205..073cd7e9727 100644 --- a/src/shared/browser-url.test.ts +++ b/src/shared/browser-url.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { isEligibleLocalCertificateHost, @@ -43,6 +43,30 @@ describe('browser-url helpers', () => { expect(normalizeExternalBrowserUrl('about:blank')).toBeNull() }) + describe('file path backslashes', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('preserves backslashes as filename characters on POSIX hosts', () => { + vi.stubGlobal('navigator', { userAgent: 'Linux' }) + expect(normalizeBrowserNavigationUrl('/tmp/site\\draft.html')).toBe( + 'file:///tmp/site%5Cdraft.html' + ) + }) + + it('normalizes backslashes to separators on Windows hosts', () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + expect(normalizeBrowserNavigationUrl('/foo\\bar.html')).toBe('file:///foo/bar.html') + expect(normalizeBrowserNavigationUrl('C:\\site\\draft.html')).toBe( + 'file:///C:/site/draft.html' + ) + expect(normalizeBrowserNavigationUrl('\\\\server\\share\\draft.html')).toBe( + 'file://server/share/draft.html' + ) + }) + }) + // Refuse file URLs externally: a remote page must not drive Finder/Explorer to arbitrary paths. it('rejects file:// for external opens even though it is allowed in-app', () => { expect(normalizeExternalBrowserUrl('file:///etc/passwd')).toBeNull() diff --git a/src/shared/browser-url.ts b/src/shared/browser-url.ts index 12efcc945b2..2bd74314c0a 100644 --- a/src/shared/browser-url.ts +++ b/src/shared/browser-url.ts @@ -1,5 +1,6 @@ import './constants' import { ORCA_BROWSER_BLANK_URL } from './browser/browser-blank-page' +import { getCurrentLocalPlatform } from './execution-host' const LOCAL_ADDRESS_PATTERN = /^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[[0-9a-f:]+\])(?::\d+)?(?:[/?#].*)?$/i @@ -245,7 +246,9 @@ export function isAbsoluteFilesystemPathInput(input: string): boolean { } function absolutePathToFileUrl(filePath: string): string { - const normalizedPath = filePath.replaceAll('\\', '/') + // Why: `\` is a filename char on POSIX but a separator on Windows, even in rooted paths like `/foo\bar`. + const isPosixPath = filePath.startsWith('/') && getCurrentLocalPlatform() !== 'win32' + const normalizedPath = isPosixPath ? filePath : filePath.replaceAll('\\', '/') const segments = normalizedPath.split('/').map((segment, index) => { if (index === 0 && /^[A-Za-z]:$/.test(segment)) { return segment diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts index bbceeb3d444..ceaadfc14e8 100644 --- a/src/shared/execution-host.ts +++ b/src/shared/execution-host.ts @@ -23,7 +23,7 @@ export type ParsedExecutionHost = | { kind: 'ssh'; id: `ssh:${string}`; targetId: string } | { kind: 'runtime'; id: `runtime:${string}`; environmentId: string } -function getCurrentLocalPlatform(): NodeJS.Platform | null { +export function getCurrentLocalPlatform(): NodeJS.Platform | null { const globalNavigator = (globalThis as { navigator?: { userAgent?: string; platform?: string } }) .navigator const userAgent = globalNavigator?.userAgent || globalNavigator?.platform || ''