diff --git a/src/index.ts b/src/index.ts index e413069..e87ff4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ import { type SidebarPrefs, } from './config.ts' import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' +import { resolveSessionPath } from './session-path.ts' import { writeWorkspaceUpload } from './fs-operations.ts' import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts' import { searchFiles } from './fs-search.ts' @@ -156,7 +157,7 @@ function selectedRepoOf(payload: unknown): string | undefined { * cwd when the root cannot be resolved, e.g. a bare directory). */ async function resolveGitPath(cwd: string, raw: string, selected?: string): Promise { - if (isAbsolute(raw)) return requireAbsolute(raw) + if (isAbsolute(raw)) return requireAbsolute(resolveSessionPath(cwd, raw)) // Prefer the session-relative interpretation when it names an existing // path. Git status reports repository-root-relative names, but the sidebar // security boundary is the session workspace; this preference keeps files diff --git a/src/path-security.ts b/src/path-security.ts index a1556d3..fb31027 100644 --- a/src/path-security.ts +++ b/src/path-security.ts @@ -2,6 +2,7 @@ import { realpath } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { isWithin, requireAbsolute } from './fs-tree.ts' +import { resolveSessionPath } from './session-path.ts' import { SidebarError } from './wire.ts' /** Resolve a path and convert filesystem resolution failures to an API error. */ @@ -25,14 +26,14 @@ function assertWithinWorkspace(workspace: string, target: string): void { * enforce containment. * * @param cwd - Session workspace directory. - * @param target - Client-supplied absolute path. + * @param target - Client-supplied absolute path in the session's namespace. * @param fence - Whether containment is enforced (the settings-page * `workspaceFence` switch). Even when false the paths are still resolved * through symlinks so callers always receive the canonical target. * @returns The canonical absolute path used for the filesystem operation. */ export async function ensureWorkspacePath(cwd: string, target: string, fence = true): Promise { - const absolute = requireAbsolute(target) + const absolute = requireAbsolute(resolveSessionPath(cwd, target)) const [realCwd, realTarget] = await Promise.all([ resolveRealPath(cwd, 'workspace'), resolveRealPath(absolute, 'target'), @@ -49,13 +50,13 @@ export async function ensureWorkspacePath(cwd: string, target: string, fence = t * symlink is never left in the path passed to the write operation. * * @param cwd - Session workspace directory. - * @param target - Client-supplied absolute destination path. + * @param target - Client-supplied absolute destination path in the session's namespace. * @param fence - Whether containment is enforced (the settings-page * `workspaceFence` switch). Resolution/canonicalization is identical either way. * @returns A canonical path for an existing target or its nearest existing ancestor. */ export async function ensureWorkspaceWritePath(cwd: string, target: string, fence = true): Promise { - const absolute = requireAbsolute(target) + const absolute = requireAbsolute(resolveSessionPath(cwd, target)) const realCwd = await resolveRealPath(cwd, 'workspace') let existingPath = absolute const missingSegments: string[] = [] diff --git a/src/session-path.ts b/src/session-path.ts new file mode 100644 index 0000000..040058e --- /dev/null +++ b/src/session-path.ts @@ -0,0 +1,37 @@ +import { win32 } from 'node:path' + +/** `\\wsl.localhost\` root of a Windows-hosted WSL workspace. */ +const WSL_LOCALHOST_ROOT = /^\\\\wsl\.localhost\\([^\\]+)(?:\\|$)/i + +/** + * Reinterpret an already-absolute path in the namespace of one session. + * + * Windows treats `/foo` as rooted on the current drive, so `path.resolve()` + * turns it into e.g. `C:\\foo`. That is wrong for a session whose cwd is a + * WSL UNC path: in that namespace `/foo` means the distro's Linux `/foo`. + * Project only that unambiguous combination onto the matching distro root; + * drive paths, UNC paths, ordinary Windows sessions and non-Windows hosts + * keep their existing semantics. + * + * Workspace containment is intentionally NOT handled here. A projected path + * such as `/tmp/x` can still be rejected later for lying outside the session + * workspace; the important part is that it is checked as WSL `/tmp/x`, not + * silently redirected to `C:\\tmp\\x`. + */ +export function resolveSessionPath( + cwd: string, + target: string, + platform: NodeJS.Platform = process.platform, +): string { + if (platform !== 'win32' || !/^\/(?!\/)/.test(target)) return target + + // Session cwd can be spelled with either separator style. Normalize only + // for detection; the returned host path is always canonical win32 UNC. + const normalizedCwd = cwd.replace(/\//g, '\\') + const match = WSL_LOCALHOST_ROOT.exec(normalizedCwd) + if (match === null) return target + + const distroRoot = `\\\\wsl.localhost\\${match[1]}` + const relative = target.slice(1).replace(/\//g, '\\') + return win32.resolve(distroRoot, relative) +} diff --git a/tests/session-path.spec.ts b/tests/session-path.spec.ts new file mode 100644 index 0000000..07508f8 --- /dev/null +++ b/tests/session-path.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { resolveSessionPath } from '../src/session-path.ts' + +const WSL_CWD = '\\\\wsl.localhost\\archlinux\\home\\zdaar\\project' + +describe('resolveSessionPath', () => { + it('maps a Linux absolute path into the WSL distro root on win32', () => { + expect(resolveSessionPath(WSL_CWD, '/home/zdaar/project/src/a.ts', 'win32')) + .toBe('\\\\wsl.localhost\\archlinux\\home\\zdaar\\project\\src\\a.ts') + }) + + it('maps a path outside the workspace into the same distro before containment checks', () => { + expect(resolveSessionPath(WSL_CWD, '/tmp/dsh-issue.md', 'win32')) + .toBe('\\\\wsl.localhost\\archlinux\\tmp\\dsh-issue.md') + }) + + it('normalizes Linux dot segments without escaping the distro share root', () => { + expect(resolveSessionPath(WSL_CWD, '/home/zdaar/project/../other/a.ts', 'win32')) + .toBe('\\\\wsl.localhost\\archlinux\\home\\zdaar\\other\\a.ts') + expect(resolveSessionPath(WSL_CWD, '/../etc/hosts', 'win32')) + .toBe('\\\\wsl.localhost\\archlinux\\etc\\hosts') + }) + + it('preserves Windows drive paths in a WSL session', () => { + const target = 'C:\\Users\\zdaar\\file.md' + expect(resolveSessionPath(WSL_CWD, target, 'win32')).toBe(target) + }) + + it('preserves UNC paths in a WSL session', () => { + const backslash = '\\\\server\\share\\file.md' + const forwardSlash = '//server/share/file.md' + expect(resolveSessionPath(WSL_CWD, backslash, 'win32')).toBe(backslash) + expect(resolveSessionPath(WSL_CWD, forwardSlash, 'win32')).toBe(forwardSlash) + }) + + it('does not reinterpret slash-rooted paths for ordinary Windows sessions', () => { + expect(resolveSessionPath('C:\\Users\\zdaar\\project', '/tmp/a.ts', 'win32')).toBe('/tmp/a.ts') + expect(resolveSessionPath('\\\\server\\share\\project', '/tmp/a.ts', 'win32')).toBe('/tmp/a.ts') + }) + + it('does not reinterpret paths on non-Windows hosts', () => { + expect(resolveSessionPath('/home/zdaar/project', '/tmp/a.ts', 'linux')).toBe('/tmp/a.ts') + expect(resolveSessionPath('/Users/zdaar/project', '/tmp/a.ts', 'darwin')).toBe('/tmp/a.ts') + }) + + it('recognizes wsl.localhost case-insensitively and with forward-slash cwd spelling', () => { + expect(resolveSessionPath('//WSL.LOCALHOST/Ubuntu/home/me/project', '/home/me/project/a.ts', 'win32')) + .toBe('\\\\wsl.localhost\\Ubuntu\\home\\me\\project\\a.ts') + }) +})