feat: 统一路径处理——UNC/软链接分类/html 路由平台守卫(#65 #67 #43 #79 #115)

- fs-tree: requireAbsolute 改用 path.isAbsolute(win32 UNC 放行、C:foo 仍拒);
  目录列表仅对 symlink 行 stat 一次,暴露 isSymlink/broken,目录软链接可展开
- html-route: UNC 路径经 '//' 标记编码往返;标记仅在 Windows 风格会话启用,
  POSIX '//' 绝对路径不再被误判(平台守卫)
- client: isAbsolutePath/isWindowsStylePath 镜像 + resolveSidebarPath 识别 UNC,
  htmlUrl 以会话 cwd 形态传递平台信号;FsEntry 增加 isSymlink/broken
- explorer: 软链接行链接徽标、悬空链接标红 + tooltip(issue #65)
- 测试: tests/platform.ts 守卫助手;win32 专属用例改为 skipIf 可见跳过,
  补 POSIX 反向分支与 UNC 矩阵;新增 fs-tree-symlink.spec
This commit is contained in:
Menghuan1918
2026-08-16 23:24:43 +08:00
parent bb3b40ff15
commit db189e3852
16 changed files with 424 additions and 62 deletions
+5 -3
View File
@@ -14,7 +14,7 @@ import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNo
import clsx from 'clsx'
import {
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
IconRefreshOutline16, Menu, writeClipboard,
IconLinkOutline16, IconRefreshOutline16, Menu, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { api, downloadUrl, type FsEntry } from './api.ts'
import { relativeTo } from './paths.ts'
@@ -163,6 +163,7 @@ export function ExplorerView(props: {
>
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
</div>
{isOpen && renderLevel(entry.path, depth + 1)}
@@ -174,9 +175,9 @@ export function ExplorerView(props: {
key={entry.path}
role="button"
tabIndex={0}
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken)}
style={{ paddingLeft: depth * 22 + 6 }}
title={entry.path}
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
onClick={() => { onOpenFile(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -188,6 +189,7 @@ export function ExplorerView(props: {
>
<IconCodeOutline16 size={14} />
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
</div>
)
+13 -4
View File
@@ -8,6 +8,7 @@
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { BrowserProbeResult } from './browser.ts'
import { isWindowsStylePath } from './paths.ts'
/** One wire failure. */
export class SidebarApiError extends Error {
@@ -25,6 +26,10 @@ export interface FsEntry {
path: string
isDir: boolean
hidden: boolean
/** Whether the row is a symlink; `isDir` then describes the link's target. */
isSymlink: boolean
/** For symlinks: the target is missing or unreadable (stat failed). */
broken: boolean
}
/** Git status entry (host git shape). */
@@ -207,9 +212,13 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
return `/sidebar/file?${params.toString()}`
}
/** Absolute URL of the HTML preview route (see html-route.ts): the path is
* fully encoded so the previewed page's relative assets resolve back into
* the same route with the session scope intact. */
/**
* Absolute URL of the HTML preview route (see html-route.ts): the path is
* fully encoded so the previewed page's relative assets resolve back into
* the same route with the session scope intact. The session cwd's shape
* supplies the platform guard for the UNC marker (the browser OS may differ
* from the host OS, so no client-side platform detection is used).
*/
export function htmlUrl(scope: SessionScope, path: string): string {
return encodeHtmlUrl(scope.sessionId, path)
return encodeHtmlUrl(scope.sessionId, path, isWindowsStylePath(scope.cwd ?? ''))
}
+2
View File
@@ -17,6 +17,7 @@ export const zh = {
editor: '编辑器',
newTab: '新建标签页',
openExplorer: '资源管理器',
brokenSymlink: '失效的软链接',
openGit: 'Git 面板',
newTerminal: '新终端',
terminalLimit: '终端数量已达上限 (3)',
@@ -239,6 +240,7 @@ export const en: Record<keyof typeof zh, string> = {
editor: 'Editor',
newTab: 'New tab',
openExplorer: 'Explorer',
brokenSymlink: 'Broken symlink',
openGit: 'Git panel',
newTerminal: 'New terminal',
terminalLimit: 'Terminal limit reached (3)',
+30
View File
@@ -3,8 +3,38 @@
* the session cwd (for the @-reference button and "copy relative path").
* The fs-tree joins with '/' even on Windows, so both separators normalize
* to '/' before comparison.
*
* This module is dependency-free (no node:path in the client bundle): the
* host is the authority for path semantics, so these mirrors deliberately
* accept a SUPERSET of absolute forms — anything a Windows host would emit
* (drive letters, UNC) plus POSIX roots. A form the host would reject
* (e.g. a backslash UNC path on a POSIX host) passes through here and then
* fails loudly in the host's requireAbsolute instead of being silently
* joined onto the cwd.
*/
/**
* Whether a path looks like it belongs to a Windows-style session: a drive
* letter or any backslash (UNC shares, and drive paths in general).
* Used as the platform signal for the html route's UNC marker — the client
* cannot read process.platform (the browser OS may differ from the host),
* but the session cwd shape is authoritative.
*/
export function isWindowsStylePath(path: string): boolean {
return path.includes('\\') || /^[A-Za-z]:[\\/]/.test(path)
}
/**
* Mirror of the host's absolute-path notion (see fs-tree.requireAbsolute):
* POSIX roots, Windows drive letters, and Windows UNC network shares in
* both backslash (`\\server\share\...`) and forward-slash
* (`//server/share/...`) form. Deliberately a superset — see the module
* comment — so a produced UNC path is never joined onto the cwd.
*/
export function isAbsolutePath(path: string): boolean {
return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/]/.test(path)
}
/**
* The path relative to the session's working directory.
* @param cwd - the explorer root (absolute).
+7 -3
View File
@@ -6,6 +6,7 @@
* Kept dependency-free so the takeover logic is unit-testable and the
* replica is easy to diff against upstream when it drifts.
*/
import { isAbsolutePath } from './paths.ts'
/** Paths a tool-result view reports as produced, by render intent. */
export function producedPaths(view: unknown): readonly string[] {
@@ -76,10 +77,13 @@ export function selectProducedFiles(owner: unknown): readonly string[] | null {
return paths.length === 0 ? null : paths
}
/** Resolve a (possibly relative) path against the session cwd for the sidebar. */
/**
* Resolve a (possibly relative) path against the session cwd for the sidebar.
* Absolute detection mirrors the host (see client/paths.isAbsolutePath):
* POSIX roots, drive letters and UNC shares must not be joined onto the cwd.
*/
export function resolveSidebarPath(cwd: string | undefined, path: string): string {
const absolute = path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
if (absolute) return path
if (isAbsolutePath(path)) return path
const base = cwd ?? ''
if (base === '') return path
const separator = base.includes('\\') ? '\\' : '/'
+12
View File
@@ -703,6 +703,18 @@
opacity: 0.45;
}
/* The link badge on symlink rows (folder and file rows alike). */
.explorerSymlink {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
/* A symlink whose target is missing: error ink on the name (the row stays
clickable — the editor surfaces the read failure). */
.explorerBroken .explorerName {
color: var(--dsw-alias-state-error-primary);
}
.explorerName {
overflow: hidden;
text-overflow: ellipsis;
+32 -10
View File
@@ -2,11 +2,13 @@
* Single-level directory listing for the sidebar explorer. Streams the level
* with opendir, sorts directories first then names (case-insensitive), and
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
* reported as files without probing their target — the explorer shows what
* dirent says, keeping the read cheap for arbitrarily large levels.
* stat'ed once to expose their target kind — a symlink to a directory
* expands like a directory — and dangling links are flagged broken. The
* probe runs only for entries that are actually symlinks, so levels without
* links stay as cheap as before.
*/
import { opendir } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import { opendir, stat } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import { SidebarError } from './wire.ts'
/** One explorer row. */
@@ -15,6 +17,10 @@ export interface SidebarFsEntry {
path: string
isDir: boolean
hidden: boolean
/** Whether the row is a symlink; `isDir` then describes the link's target. */
isSymlink: boolean
/** For symlinks: the target is missing or unreadable (stat failed). */
broken: boolean
}
/** One listed level. */
@@ -52,12 +58,22 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
overflow += 1
continue
}
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
// would leak mixed separators into every row's path.
const fullPath = join(path, dirent.name)
const isSymlink = dirent.isSymbolicLink()
// Probe the link's target once so a symlinked directory renders as an
// expandable directory row. stat follows the chain; any failure
// (missing target, ELOOP, permission) leaves the row as a broken
// file-shaped link the editor will refuse to read. Non-symlink entries
// skip the syscall entirely — dirent already classified them.
const info = isSymlink ? await stat(fullPath).catch(() => undefined) : undefined
rows.push({
name: dirent.name,
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
// would leak mixed separators into every row's path.
path: join(path, dirent.name),
isDir: dirent.isDirectory(),
path: fullPath,
isDir: info !== undefined ? info.isDirectory() : dirent.isDirectory(),
isSymlink,
broken: isSymlink && info === undefined,
hidden: dirent.name.startsWith('.'),
})
}
@@ -80,9 +96,15 @@ export function parentOf(path: string): string | undefined {
return parent === path ? undefined : parent
}
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
/**
* Normalize a caller-supplied path to an absolute, resolved path or throw
* fs-error. `path.isAbsolute()` is the OS's own notion of absolute: POSIX
* roots (`/...`), Windows drive letters (`C:\...`) and — on win32 — UNC
* network shares (`\\server\share\...`); drive-relative forms (`C:foo`)
* stay rejected.
*/
export function requireAbsolute(path: string): string {
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
if (!isAbsolute(path)) {
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
}
return resolve(path)
+50 -18
View File
@@ -13,6 +13,16 @@
* /sidebar/html/S/Users/me/proj/index.html
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
* Windows UNC (windowsPathStyle): \\server\share\proj\a.html
* → /sidebar/html/S//server/share/proj/a.html ('//' right after the
* sessionId marks the UNC prefix; the WHATWG URL keeps '//' intact so
* relative assets still resolve inside the same route)
*
* The `windowsPathStyle` flag is the platform guard: a leading `//` is a
* legitimate POSIX absolute path, so the UNC marker is only emitted for
* sessions whose cwd shape identifies them as Windows (see the client's
* isWindowsStylePath) — otherwise a POSIX `//server/share/...` would decode
* back to a backslash UNC form the host then rejects.
*
* This module is intentionally dependency-free (no node imports, no wire
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
@@ -35,25 +45,32 @@ export type HtmlDecodeResult =
/** The route prefix both encoders/decoders agree on. */
export const HTML_ROUTE_PREFIX = '/sidebar/html/'
/** Build the route URL for one absolute file path (client + tests). */
export function encodeHtmlUrl(sessionId: string, path: string): string {
/**
* Build the route URL for one absolute file path (client + tests).
* @param windowsPathStyle - whether the path belongs to a Windows-style
* session (drive or UNC). Only then is a `\\`/`//`-prefixed UNC path marked
* with the '//' marker; POSIX sessions keep `//` as an ordinary absolute
* path (see the module comment for the platform guard rationale).
*/
export function encodeHtmlUrl(sessionId: string, path: string, windowsPathStyle = false): string {
const unc = windowsPathStyle && /^[\\/]{2}[^\\/]/.test(path)
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${segments.map(encodeURIComponent).join('/')}`
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}`
}
/**
* Decode a route pathname into the session + absolute file path. Rejects
* a wrong prefix (404), an empty or double-slash path, malformed percent
* encoding, and a missing sessionId or file path (400). The caller still
* must bound the decoded path with requireAbsolute + isWithin(cwd) — a
* decoded `..` segment resolves outside the cwd and is refused there.
* a wrong prefix (404), an empty path, malformed percent encoding, and a
* missing sessionId or file path (400). The caller still must bound the
* decoded path with requireAbsolute + isWithin(cwd) — a decoded `..`
* segment resolves outside the cwd and is refused there.
*/
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
return { ok: false, status: 404, message: 'not an html route' }
}
const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
if (rest === '' || rest.includes('//')) {
if (rest === '') {
return { ok: false, status: 400, message: 'invalid html route path' }
}
let segments: string[]
@@ -63,17 +80,32 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
return { ok: false, status: 400, message: 'malformed URL encoding' }
}
const [sessionId, ...pathSegments] = segments
if (sessionId === undefined || sessionId === '' || pathSegments.length === 0 || pathSegments.some(segment => segment === '')) {
if (sessionId === undefined || sessionId === '') {
return { ok: false, status: 400, message: 'sessionId and file path are required' }
}
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
// the html route's isWithin(cwd) fence would then reject every drive path.
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
const first = pathSegments[0] ?? ''
const path = /^[A-Za-z]:$/.test(first)
? pathSegments.join('/')
: `/${pathSegments.join('/')}`
// An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits
// '<sid>//server/share/...' for Windows UNC paths); the encoder filters
// empty segments everywhere else, so an empty segment can only be the
// marker or a malformed URL — both handled here.
const unc = pathSegments[0] === ''
const tail = unc ? pathSegments.slice(1) : pathSegments
if (tail.length === 0 || tail.some(segment => segment === '')) {
return { ok: false, status: 400, message: 'sessionId and file path are required' }
}
let path: string
if (unc) {
// Rebuild the UNC form \\server\share\... so requireAbsolute() +
// isWithin(cwd) see the same path the explorer works with.
path = `\\\\${tail.join('\\')}`
} else if (/^[A-Za-z]:$/.test(tail[0] ?? '')) {
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
// the html route's isWithin(cwd) fence would then reject every drive path.
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
path = tail.join('/')
} else {
path = `/${tail.join('/')}`
}
return { ok: true, ref: { sessionId, path } }
}