test(desktop): cover the WSL bridge on every file-read boundary

The previous change wired resolveLocalReadPath into the media protocol,
readFileDataUrl, readFileText and previewFileTarget handlers inline, but
nothing locked that wiring in: dropping the bridge from any one boundary
would silently reintroduce the ENOENT on a Windows host with a WSL
backend, and wsl-path-bridge.test.ts only exercises the translation, not
the call sites (flagged by the upstream review).

Extract each boundary's path preparation into local-read-path.ts, the
same way fs-read-dir.ts already isolates the directory boundary, and have
main.ts delegate to it. The handlers keep byte-for-byte behavior; the
step is now reachable in isolation, so local-read-path.test.ts mocks
resolveLocalReadPath and asserts each boundary routes its raw path
through it (decoded media pathname, coerced IPC path, expanded-then-
bridged preview target, file: passthrough).
This commit is contained in:
null-runner
2026-07-20 15:54:20 +02:00
committed by brooklyn!
parent 2df349060a
commit 7722761581
3 changed files with 127 additions and 7 deletions

View File

@@ -0,0 +1,83 @@
import assert from 'node:assert/strict'
import { beforeEach, test, vi } from 'vitest'
// resolveLocalReadPath is the WSL->Windows bridge every file-read boundary must
// route its raw path through on a Windows host. Spy on it so each test asserts
// the boundary *applies* the bridge (and with what argument) rather than
// re-testing the translation itself, which wsl-path-bridge.test.ts already covers.
const { bridge } = vi.hoisted(() => ({ bridge: vi.fn() }))
vi.mock('./wsl-path-bridge', () => ({ resolveLocalReadPath: bridge }))
import { resolveIpcFileReadPath, resolveMediaRequestPath, resolvePreviewTargetPath } from './local-read-path'
const BRIDGED = '\\\\wsl.localhost\\Ubuntu\\home\\alex\\file'
beforeEach(() => {
bridge.mockReset()
bridge.mockImplementation(() => BRIDGED)
})
test('resolveMediaRequestPath (hermes-media:// handler) bridges the decoded request pathname', () => {
// Mirror how the renderer builds the URL: hermes-media://stream/<encoded path>.
const url = new URL(`hermes-media://stream/${encodeURIComponent('/home/alex/My Clips/clip.mp4')}`)
const result = resolveMediaRequestPath(url.pathname)
assert.equal(bridge.mock.calls.length, 1)
assert.equal(bridge.mock.calls[0][0], '/home/alex/My Clips/clip.mp4')
assert.equal(result, BRIDGED)
})
test('resolveMediaRequestPath strips leading slashes before decoding and bridging', () => {
resolveMediaRequestPath(`///${encodeURIComponent('/mnt/c/Users/alex/clip.mp4')}`)
assert.equal(bridge.mock.calls.length, 1)
assert.equal(bridge.mock.calls[0][0], '/mnt/c/Users/alex/clip.mp4')
})
test('resolveMediaRequestPath tolerates nullish pathname', () => {
const result = resolveMediaRequestPath(undefined as unknown as string)
assert.equal(bridge.mock.calls.length, 1)
assert.equal(bridge.mock.calls[0][0], '')
assert.equal(result, BRIDGED)
})
test('resolveIpcFileReadPath (hermes:readFileDataUrl / hermes:readFileText) bridges the supplied path', () => {
const result = resolveIpcFileReadPath('/home/alex/notes.txt')
assert.equal(bridge.mock.calls.length, 1)
assert.equal(bridge.mock.calls[0][0], '/home/alex/notes.txt')
assert.equal(result, BRIDGED)
})
test('resolveIpcFileReadPath coerces a nullish path to an empty string before bridging', () => {
resolveIpcFileReadPath(null)
assert.equal(bridge.mock.calls.length, 1)
assert.equal(bridge.mock.calls[0][0], '')
})
test('resolvePreviewTargetPath (previewFileTarget) expands and bridges a plain backend target', () => {
const expandUserPath = vi.fn((value: string) => value.replace('~', '/home/alex'))
const result = resolvePreviewTargetPath('~/docs/readme.md', expandUserPath)
assert.equal(expandUserPath.mock.calls.length, 1)
assert.equal(expandUserPath.mock.calls[0][0], '~/docs/readme.md')
assert.equal(bridge.mock.calls.length, 1)
assert.equal(bridge.mock.calls[0][0], '/home/alex/docs/readme.md')
assert.equal(result, BRIDGED)
})
test('resolvePreviewTargetPath passes file: URLs through the bridge without expanding', () => {
const expandUserPath = vi.fn((value: string) => value)
resolvePreviewTargetPath('file:///home/alex/report.html', expandUserPath)
assert.equal(expandUserPath.mock.calls.length, 0)
assert.equal(bridge.mock.calls.length, 1)
assert.equal(bridge.mock.calls[0][0], 'file:///home/alex/report.html')
})

View File

@@ -0,0 +1,33 @@
import { resolveLocalReadPath } from './wsl-path-bridge'
// Path preparation shared by the file-read boundaries that hand a backend-
// reported target to the hardening resolvers. On a Windows host with a WSL
// backend the target is a WSL/POSIX path (`/home/...`, `/mnt/c/...`) the
// Windows fs can't open as-is, so each boundary must route the raw path
// through resolveLocalReadPath (a no-op off Windows / for already-Windows
// paths) before it reaches the resolver. Kept here — mirroring fs-read-dir —
// so the wiring is exercised in isolation instead of buried in main.ts.
/**
* hermes-media:// stream handler: the request `pathname` (leading slashes plus
* percent-encoding) → a bridged fs path.
*/
export function resolveMediaRequestPath(pathname: string): string {
return resolveLocalReadPath(decodeURIComponent(String(pathname ?? '').replace(/^\/+/, '')))
}
/**
* hermes:readFileDataUrl / hermes:readFileDataUrlForAttach / hermes:readFileText
* IPC handlers: a renderer-supplied path → a bridged fs path.
*/
export function resolveIpcFileReadPath(filePath: unknown): string {
return resolveLocalReadPath(String(filePath ?? ''))
}
/**
* previewFileTarget: `file:` URLs pass through untouched, plain targets get `~`
* expanded, and the result is bridged before it reaches resolveRequestedPathForIpc.
*/
export function resolvePreviewTargetPath(rawTarget: string, expandUserPath: (value: string) => string): string {
return resolveLocalReadPath(/^file:/i.test(rawTarget) ? rawTarget : expandUserPath(rawTarget))
}

View File

@@ -279,6 +279,8 @@ import {
tightenSecretFileMode,
writeSecretFileAtomic
} from './hardening'
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
import { resolveIpcFileReadPath, resolveMediaRequestPath, resolvePreviewTargetPath } from './local-read-path'
import {
type AttachedBackend,
attachOrReserveSpawn,
@@ -600,7 +602,7 @@ import { installWindowsSystemCaTrust } from './windows-system-ca'
import { readWindowsUserEnvVar } from './windows-user-env'
import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd'
import { readWslWindowsClipboardImage } from './wsl-clipboard-image'
import { resolveLocalReadPath, resolvePickerDefaultPath, setActiveGatewayProfile, setWslBridgeProfileState } from './wsl-path-bridge'
import { resolvePickerDefaultPath, setActiveGatewayProfile, setWslBridgeProfileState } from './wsl-path-bridge'
const IDENTITY_APP_NAME: string | null = applyDesktopIdentity(app)
const USER_DATA_OVERRIDE: string | undefined = process.env.HERMES_DESKTOP_USER_DATA_DIR
@@ -1518,7 +1520,7 @@ function registerMediaProtocol(): void {
// On a Windows host with a WSL backend the media path arrives as a
// WSL/POSIX path (`/home/...`, `/mnt/c/...`) the Windows fs can't open
// as-is; bridge it to a UNC/drive form first, same as directory reads.
const { resolvedPath } = await resolveReadableFileForIpc(resolveLocalReadPath(filePath), {
const { resolvedPath } = await resolveReadableFileForIpc(resolveMediaRequestPath(filePath), {
purpose: 'Media stream'
})
@@ -5958,7 +5960,7 @@ async function previewFileTarget(rawTarget, baseDir) {
// A plain backend target is a WSL/POSIX path; bridge it to a Windows-
// accessible form before resolving so the existence checks below (and the
// final read) hit the real file rather than a drive-relative C:\home\... miss.
let resolved = resolveRequestedPathForIpc(resolveLocalReadPath(/^file:/i.test(raw) ? raw : expandUserPath(raw)), {
let resolved = resolveRequestedPathForIpc(resolvePreviewTargetPath(raw, expandUserPath), {
baseDir: base,
purpose: 'Preview target'
})
@@ -17117,7 +17119,7 @@ ipcMain.handle('hermes:data-url-read-max:set', (_event, maxMb) => {
ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
// Backend-reported paths are WSL/POSIX (`/home/...`, `/mnt/c/...`); on a
// Windows host bridge them to a UNC/drive form, same as directory reads.
const bridgedPath = resolveLocalReadPath(String(filePath ?? ''))
const bridgedPath = resolveIpcFileReadPath(filePath)
return readFileDataUrlForIpc(bridgedPath, {
maxBytes: dataUrlReadMaxBytesFromMb(dataUrlReadMaxMb),
@@ -17131,15 +17133,17 @@ ipcMain.handle('hermes:readFileDataUrl', async (_event, filePath) => {
// can exceed the default 16 MiB preview ceiling (and still fit the gateway
// WebSocket frame limit after base64 expansion).
ipcMain.handle('hermes:readFileDataUrlForAttach', async (_event, filePath) => {
return readFileDataUrlForIpc(filePath, {
const bridgedPath = resolveIpcFileReadPath(filePath)
return readFileDataUrlForIpc(bridgedPath, {
maxBytes: ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
mimeType: mimeTypeForPath(resolveRequestedPathForIpc(filePath, { purpose: 'Attachment upload' })),
mimeType: mimeTypeForPath(resolveRequestedPathForIpc(bridgedPath, { purpose: 'Attachment upload' })),
purpose: 'Attachment upload'
})
})
ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
const { resolvedPath, stat } = await resolveReadableFileForIpc(resolveLocalReadPath(String(filePath ?? '')), {
const { resolvedPath, stat } = await resolveReadableFileForIpc(resolveIpcFileReadPath(filePath), {
maxBytes: TEXT_PREVIEW_SOURCE_MAX_BYTES,
purpose: 'Text preview'
})