fix: 修复 WSL 会话 Linux 路径解析 (#399)

This commit is contained in:
Kunping Wang
2026-09-02 23:20:17 +08:00
committed by Menghuan1918
parent dde0923980
commit 180b16de1b
4 changed files with 94 additions and 5 deletions
+2 -1
View File
@@ -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<string> {
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
+5 -4
View File
@@ -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<string> {
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<string> {
const absolute = requireAbsolute(target)
const absolute = requireAbsolute(resolveSessionPath(cwd, target))
const realCwd = await resolveRealPath(cwd, 'workspace')
let existingPath = absolute
const missingSegments: string[] = []
+37
View File
@@ -0,0 +1,37 @@
import { win32 } from 'node:path'
/** `\\wsl.localhost\<distro>` 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)
}
+50
View File
@@ -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')
})
})