From db189e3852b2e7f816cda4b7f342974c4a82c75c Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 16 Aug 2026 19:25:00 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=A4=84=E7=90=86=E2=80=94=E2=80=94UNC/=E8=BD=AF?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E5=88=86=E7=B1=BB/html=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=AE=88=E5=8D=AB=EF=BC=88#65=20#67=20#43=20?= =?UTF-8?q?#79=20#115=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 2 +- README_EN.md | 2 +- docs/external-plugin-guide.md | 2 +- src/client/ExplorerView.tsx | 8 ++-- src/client/api.ts | 17 +++++-- src/client/locales.ts | 2 + src/client/paths.ts | 30 ++++++++++++ src/client/produced-files.ts | 10 ++-- src/client/sidebar.module.css | 12 +++++ src/fs-tree.ts | 42 +++++++++++++---- src/html-route.ts | 68 +++++++++++++++++++------- tests/fs-tree-symlink.spec.ts | 89 +++++++++++++++++++++++++++++++++++ tests/fs-tree.spec.ts | 74 ++++++++++++++++++++++------- tests/html-route.spec.ts | 59 +++++++++++++++++++++-- tests/paths.spec.ts | 56 +++++++++++++++++++++- tests/platform.ts | 13 +++++ 16 files changed, 424 insertions(+), 62 deletions(-) create mode 100644 tests/fs-tree-symlink.spec.ts create mode 100644 tests/platform.ts diff --git a/README.md b/README.md index 533ce48..43ee559 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ## ✨ 功能一览 -- **🗂️ 文件工作台**:资源管理器(懒加载目录树)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览 +- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览 - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原 diff --git a/README_EN.md b/README_EN.md index 8c2c779..df6dacf 100644 --- a/README_EN.md +++ b/README_EN.md @@ -21,7 +21,7 @@ ## ✨ Features -- **🗂️ File Workbench**: file explorer (lazy-loading tree) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office +- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office - **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings) - **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model - **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 6f28f87..cb3cea7 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -480,7 +480,7 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me | 方法 | 说明 | |---|---| | `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) | -| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`) | +| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`;FsEntry 含 `isSymlink`/`broken`,目录软链接的 `isDir` 按目标类型) | | `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) | | `fs.write` | 原子写文件 | | `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 | diff --git a/src/client/ExplorerView.tsx b/src/client/ExplorerView.tsx index 9a313b6..e21e8d1 100644 --- a/src/client/ExplorerView.tsx +++ b/src/client/ExplorerView.tsx @@ -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 ? : } {entry.name} + {entry.isSymlink && } {rowActions(entry)} {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: { > {entry.name} + {entry.isSymlink && } {rowActions(entry)} ) diff --git a/src/client/api.ts b/src/client/api.ts index 619b60a..6a56bff 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -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 ?? '')) } diff --git a/src/client/locales.ts b/src/client/locales.ts index b92e122..3eaa597 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -17,6 +17,7 @@ export const zh = { editor: '编辑器', newTab: '新建标签页', openExplorer: '资源管理器', + brokenSymlink: '失效的软链接', openGit: 'Git 面板', newTerminal: '新终端', terminalLimit: '终端数量已达上限 (3)', @@ -239,6 +240,7 @@ export const en: Record = { editor: 'Editor', newTab: 'New tab', openExplorer: 'Explorer', + brokenSymlink: 'Broken symlink', openGit: 'Git panel', newTerminal: 'New terminal', terminalLimit: 'Terminal limit reached (3)', diff --git a/src/client/paths.ts b/src/client/paths.ts index 70bf192..4247ab9 100644 --- a/src/client/paths.ts +++ b/src/client/paths.ts @@ -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). diff --git a/src/client/produced-files.ts b/src/client/produced-files.ts index a0c2a7b..4a7a2f5 100644 --- a/src/client/produced-files.ts +++ b/src/client/produced-files.ts @@ -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('\\') ? '\\' : '/' diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 2426173..570b532 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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; diff --git a/src/fs-tree.ts b/src/fs-tree.ts index 437cff7..67ddb0a 100644 --- a/src/fs-tree.ts +++ b/src/fs-tree.ts @@ -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 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) diff --git a/src/html-route.ts b/src/html-route.ts index b81a556..8b5b43b 100644 --- a/src/html-route.ts +++ b/src/html-route.ts @@ -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 + // '//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 } } } diff --git a/tests/fs-tree-symlink.spec.ts b/tests/fs-tree-symlink.spec.ts new file mode 100644 index 0000000..8211a48 --- /dev/null +++ b/tests/fs-tree-symlink.spec.ts @@ -0,0 +1,89 @@ +/** + * fs-tree symlink listing: the explorer must show a symlink as what it + * points at (a symlink to a directory expands like a directory) and flag + * links whose target is missing. The host listing keeps the probe cheap: + * only entries that are actually symlinks are stat'ed. + */ +import { describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { listDirectory } from '../src/fs-tree.ts' + +/** + * Symlink creation needs extra privileges on Windows; skip the suite there + * rather than fail (mirror of the smoke spec's platform-tolerant fixtures). + */ +const canSymlink = (() => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-probe-')) + try { + symlinkSync('target', join(dir, 'link')) + return true + } catch { + return false + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() + +/** A scratch level with real entries plus three symlinks (dir/file/dangling). */ +function makeFixture(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-')) + mkdirSync(join(dir, 'real-dir')) + writeFileSync(join(dir, 'real-file.txt'), 'content') + symlinkSync(join(dir, 'real-dir'), join(dir, 'link-to-dir')) + symlinkSync(join(dir, 'real-file.txt'), join(dir, 'link-to-file')) + symlinkSync(join(dir, 'missing-target'), join(dir, 'broken-link')) + return dir +} + +describe.skipIf(!canSymlink)('fs-tree symlink listing', () => { + it('reports a symlink to a directory as an expandable directory', async () => { + const dir = makeFixture() + try { + const listing = await listDirectory(dir) + const row = listing.entries.find(entry => entry.name === 'link-to-dir') + expect(row).toMatchObject({ isDir: true, isSymlink: true, broken: false }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('reports a symlink to a file as a file with a link badge', async () => { + const dir = makeFixture() + try { + const listing = await listDirectory(dir) + const row = listing.entries.find(entry => entry.name === 'link-to-file') + expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: false }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('flags a dangling symlink as broken', async () => { + const dir = makeFixture() + try { + const listing = await listDirectory(dir) + const row = listing.entries.find(entry => entry.name === 'broken-link') + expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: true }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('leaves regular entries unmarked and sorts symlinked dirs with the directories', async () => { + const dir = makeFixture() + try { + const listing = await listDirectory(dir) + const realDir = listing.entries.find(entry => entry.name === 'real-dir') + const realFile = listing.entries.find(entry => entry.name === 'real-file.txt') + expect(realDir).toMatchObject({ isDir: true, isSymlink: false, broken: false }) + expect(realFile).toMatchObject({ isDir: false, isSymlink: false, broken: false }) + // Directory-first ordering counts a symlinked directory as a directory. + const dirs = listing.entries.filter(entry => entry.isDir).map(entry => entry.name) + expect(dirs).toEqual(['link-to-dir', 'real-dir']) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/fs-tree.spec.ts b/tests/fs-tree.spec.ts index 4e19eb4..7037ec7 100644 --- a/tests/fs-tree.spec.ts +++ b/tests/fs-tree.spec.ts @@ -1,43 +1,76 @@ import { describe, expect, it } from 'vitest' +import { resolve } from 'node:path' import { compareEntries, isWithin, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts' +import { isPosix, isWin32 } from './platform.ts' describe('fs-tree', () => { it('sorts directories first, then names case-insensitively', () => { const rows = [ - { name: 'b.txt', path: '/x/b.txt', isDir: false, hidden: false }, - { name: 'A', path: '/x/A', isDir: true, hidden: false }, - { name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false }, - { name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true }, + { name: 'b.txt', path: '/x/b.txt', isDir: false, hidden: false, isSymlink: false, broken: false }, + { name: 'A', path: '/x/A', isDir: true, hidden: false, isSymlink: false, broken: false }, + { name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false, isSymlink: false, broken: false }, + { name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true, isSymlink: false, broken: false }, ] expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt']) }) - it('derives root labels and parents', () => { + it('derives root labels and parents (POSIX-style)', () => { // POSIX-style inputs behave identically on both platforms (win32 parses '/' // as a separator), so these assertions are platform-independent. expect(rootLabel('/Users/me/code')).toBe('code') expect(rootLabel('/')).toBe('/') expect(parentOf('/Users/me/code')).toBe('/Users/me') expect(parentOf('/')).toBeUndefined() - // Windows-drive roots and segments, asserted only where win32 semantics apply. - if (process.platform === 'win32') { - expect(rootLabel('C:\\')).toBe('C:\\') - expect(parentOf('C:\\')).toBeUndefined() - expect(rootLabel('C:\\Users\\me')).toBe('me') - expect(parentOf('C:\\Users\\me')).toBe('C:\\Users') - } }) - it('accepts absolute paths and rejects relative ones', () => { + it.skipIf(!isWin32)('derives root labels and parents for Windows drives', () => { + expect(rootLabel('C:\\')).toBe('C:\\') + expect(parentOf('C:\\')).toBeUndefined() + expect(rootLabel('C:\\Users\\me')).toBe('me') + expect(parentOf('C:\\Users\\me')).toBe('C:\\Users') + }) + + it('accepts POSIX absolute paths and rejects relative ones', () => { // resolve() is platform-native: '/a/b' roots to the current drive on win32. - expect(requireAbsolute('/a/b')).toBe(process.platform === 'win32' ? '\\a\\b' : '/a/b') - if (process.platform === 'win32') { - expect(requireAbsolute('C:/proj')).toBe('C:\\proj') - } + expect(requireAbsolute('/a/b')).toBe(resolve('/a/b')) expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/) expect(() => requireAbsolute('../a')).toThrow(/not an absolute path/) }) + // Windows-only path semantics — skipped (not silently passing) on POSIX: + // drive letters and UNC shares are absolute on win32, drive-relative 'C:foo' + // is not. + describe.skipIf(!isWin32)('win32 path semantics', () => { + it('accepts drive letters and normalizes their separators', () => { + expect(requireAbsolute('C:/proj')).toBe('C:\\proj') + expect(requireAbsolute('C:\\proj')).toBe('C:\\proj') + // A drive path resolves against its own drive, never a bare root. + expect(resolve('/a/b')).toMatch(/^[A-Za-z]:/) + }) + + it('accepts UNC network shares in both separator styles', () => { + expect(requireAbsolute('\\\\server\\share\\proj')).toBe('\\\\server\\share\\proj') + expect(requireAbsolute('//server/share/proj')).toBe('\\\\server\\share\\proj') + }) + + it('rejects drive-relative paths', () => { + expect(() => requireAbsolute('C:proj')).toThrow(/not an absolute path/) + }) + }) + + // POSIX-only path semantics — the reverse branch of the win32 suite: forms + // the host can never access must be refused loudly instead of mangled. + describe.skipIf(isWin32)('POSIX path semantics', () => { + it('rejects Windows drive paths', () => { + expect(() => requireAbsolute('C:/proj')).toThrow(/not an absolute path/) + expect(() => requireAbsolute('C:\\proj')).toThrow(/not an absolute path/) + }) + + it('rejects backslash UNC paths (not absolute on POSIX)', () => { + expect(() => requireAbsolute('\\\\server\\share\\proj')).toThrow(/not an absolute path/) + }) + }) + it('isWithin tolerates separators and (on win32) letter case', () => { expect(isWithin('/work/proj', '/work/proj/src/a.ts')).toBe(true) expect(isWithin('/work/proj', '/work/proj')).toBe(true) @@ -54,5 +87,12 @@ describe('fs-tree', () => { // Windows drive-root containment. expect(isWithin('C:\\', 'C:\\Users\\me\\a.png', 'win32')).toBe(true) expect(isWithin('c:\\users', 'C:/USERS/me/b.png', 'win32')).toBe(true) + // UNC network-share containment: the '//' share prefix must not defeat + // the prefix test, and a sibling share must stay outside. The platform + // parameter is injected, so these win32-semantics assertions run on + // every host without any platform guard. + expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj\\src\\a.ts', 'win32')).toBe(true) + expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj2\\a.ts', 'win32')).toBe(false) + expect(isWithin('\\\\server\\share\\proj', '\\\\other\\share\\a.ts', 'win32')).toBe(false) }) }) diff --git a/tests/html-route.spec.ts b/tests/html-route.spec.ts index 95b5d22..4da79ef 100644 --- a/tests/html-route.spec.ts +++ b/tests/html-route.spec.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from 'vitest' import { resolve } from 'node:path' import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts' +import { isWin32 } from './platform.ts' describe('encodeHtmlUrl', () => { it('encodes a POSIX absolute path into route segments', () => { @@ -20,6 +21,24 @@ describe('encodeHtmlUrl', () => { .toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html') }) + it('encodes a Windows UNC path with the // marker only for Windows-style sessions', () => { + // Backslash UNC is unambiguous: the marker is emitted whenever the + // caller declares the session Windows-style. + expect(encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html', true)) + .toBe('/sidebar/html/sess-1//server/share/proj/a.html') + // Forward-slash UNC needs the same marker on a Windows-style session. + expect(encodeHtmlUrl('sess-1', '//server/share/proj/a.html', true)) + .toBe('/sidebar/html/sess-1//server/share/proj/a.html') + }) + + it('keeps a POSIX // path marker-free (the platform guard)', () => { + // A leading // is a legitimate POSIX absolute path: without the + // Windows-style flag it must NOT be marked as UNC, or the host would + // decode it back into a backslash form it then rejects. + expect(encodeHtmlUrl('s-1', '//server/share/a.html')) + .toBe('/sidebar/html/s-1/server/share/a.html') + }) + it('percent-encodes special characters in segments', () => { expect(encodeHtmlUrl('s-1', '/a b/中文/100%.html')) .toBe('/sidebar/html/s-1/a%20b/%E4%B8%AD%E6%96%87/100%25.html') @@ -45,13 +64,35 @@ describe('decodeHtmlUrl', () => { ok: true, ref: { sessionId: 'sess-1', path: 'C:/Users/me/a.html' }, }) + }) + + it.skipIf(!isWin32)('decoded drive paths resolve back verbatim on win32', () => { // The host runs requireAbsolute() over the decoded path: with the old // '/C:/...' form node's resolve() produced 'C:\C:\Users\...' (drive // doubled) on Windows and the isWithin(cwd) fence rejected every drive // path. The slash-free form must resolve back to the original path. - if (process.platform === 'win32') { - expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html') - } + expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html') + }) + + it('decodes a UNC round-trip back to the network path', () => { + const url = encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html', true) + expect(decodeHtmlUrl(url)).toEqual({ + ok: true, + ref: { sessionId: 'sess-1', path: '\\\\server\\share\\proj\\a.html' }, + }) + }) + + it('decodes a marker-free POSIX // path as a plain absolute path', () => { + const url = encodeHtmlUrl('s-1', '//server/share/a.html') + expect(decodeHtmlUrl(url)).toEqual({ + ok: true, + ref: { sessionId: 's-1', path: '/server/share/a.html' }, + }) + }) + + it('refuses a marker-only UNC URL and stray double slashes (400)', () => { + expect(decodeHtmlUrl('/sidebar/html/s//').ok).toBe(false) + expect(decodeHtmlUrl('/sidebar/html/s//server//x.html').ok).toBe(false) }) it('decodes a lowercase-drive Windows path without a leading slash', () => { @@ -122,4 +163,16 @@ describe('relative asset resolution stays in-route', () => { expect(new URL('img/x.png', doc).pathname).toBe('/sidebar/html/s/a/b/img/x.png') expect(new URL('../c.css', doc).pathname).toBe('/sidebar/html/s/a/c.css') }) + + it('relative assets of a UNC document stay inside the same route', () => { + // The WHATWG URL preserves the '//' marker during relative resolution, + // so ./style.css lands back on the route with the UNC prefix intact. + const doc = `http://h${encodeHtmlUrl('s', '\\\\server\\share\\proj\\index.html', true)}` + expect(new URL('./style.css', doc).pathname) + .toBe('/sidebar/html/s//server/share/proj/style.css') + expect(decodeHtmlUrl(new URL('./style.css', doc).pathname)).toEqual({ + ok: true, + ref: { sessionId: 's', path: '\\\\server\\share\\proj\\style.css' }, + }) + }) }) diff --git a/tests/paths.spec.ts b/tests/paths.spec.ts index 55fb596..b053aee 100644 --- a/tests/paths.spec.ts +++ b/tests/paths.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' -import { relativeTo } from '../src/client/paths.ts' +import { isAbsolutePath, isWindowsStylePath, relativeTo } from '../src/client/paths.ts' import { resolveSidebarPath } from '../src/client/produced-files.ts' +import { htmlUrl } from '../src/client/api.ts' describe('path helpers', () => { it('derives relative paths under the cwd (and "." for the cwd itself)', () => { @@ -32,4 +33,57 @@ describe('path helpers', () => { expect(resolveSidebarPath('C:\\work\\proj', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts') expect(resolveSidebarPath('C:\\work\\proj\\', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts') }) + + it('keeps UNC produced paths absolute instead of joining them onto the cwd', () => { + // Pure client function: UNC detection is platform-independent, so these + // assertions run on every host without a platform guard. + expect(resolveSidebarPath('C:\\work\\proj', '\\\\server\\share\\abs\\x.ts')) + .toBe('\\\\server\\share\\abs\\x.ts') + expect(resolveSidebarPath('C:\\work\\proj', '//server/share/abs/x.ts')) + .toBe('//server/share/abs/x.ts') + // A relative path under a UNC cwd joins with backslashes. + expect(resolveSidebarPath('\\\\server\\share\\proj', 'src/a.ts')) + .toBe('\\\\server\\share\\proj\\src/a.ts') + }) + + it('mirrors the host absolute-path notion without node:path', () => { + expect(isAbsolutePath('/abs/x.ts')).toBe(true) + expect(isAbsolutePath('C:\\abs\\x.ts')).toBe(true) + expect(isAbsolutePath('C:/abs/x.ts')).toBe(true) + expect(isAbsolutePath('\\\\server\\share\\x.ts')).toBe(true) + expect(isAbsolutePath('//server/share/x.ts')).toBe(true) + expect(isAbsolutePath('C:relative.ts')).toBe(false) + expect(isAbsolutePath('rel/x.ts')).toBe(false) + }) + + it('detects windows-style session paths (platform signal for the html route)', () => { + expect(isWindowsStylePath('C:\\work\\proj')).toBe(true) + expect(isWindowsStylePath('C:/work/proj')).toBe(true) + expect(isWindowsStylePath('\\\\server\\share\\proj')).toBe(true) + expect(isWindowsStylePath('//server/share/proj')).toBe(false) // ambiguous form needs the drive/backslash signal + expect(isWindowsStylePath('/Users/me/code')).toBe(false) + }) + + it('htmlUrl marks UNC paths from the file path itself (cwd-independent signal)', () => { + // cwd not hydrated yet: the unambiguous backslash UNC path must still + // keep its '//' marker, or win32 would decode /server/... against the + // current drive and the isWithin fence would reject the preview (403). + expect(htmlUrl({ sessionId: 's' }, '\\\\server\\share\\proj\\a.html')) + .toBe('/sidebar/html/s//server/share/proj/a.html') + // A forward-slash-form cwd (still Windows) does not mask the backslash path. + expect(htmlUrl({ sessionId: 's', cwd: '//server/share/proj' }, '\\\\server\\share\\proj\\a.html')) + .toBe('/sidebar/html/s//server/share/proj/a.html') + // Both cwd and path in the forward-slash UNC form: the '//' path matches + // the '//' cwd, so it is UNC and keeps the marker (win32 round-trips). + expect(htmlUrl({ sessionId: 's', cwd: '//server/share/proj' }, '//server/share/proj/a.html')) + .toBe('/sidebar/html/s//server/share/proj/a.html') + // A Windows drive cwd marks forward-slash UNC paths. + expect(htmlUrl({ sessionId: 's', cwd: 'C:\\work' }, '//server/share/proj/a.html')) + .toBe('/sidebar/html/s//server/share/proj/a.html') + // A POSIX session keeps forward-slash paths marker-free. + expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '//server/share/a.html')) + .toBe('/sidebar/html/s/server/share/a.html') + expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '/home/me/index.html')) + .toBe('/sidebar/html/s/home/me/index.html') + }) }) diff --git a/tests/platform.ts b/tests/platform.ts new file mode 100644 index 0000000..c9633cf --- /dev/null +++ b/tests/platform.ts @@ -0,0 +1,13 @@ +/** + * Shared platform guards for tests. Use vitest's `describe.skipIf` / + * `it.skipIf` with these so platform-specific cases are REPORTED as skipped + * on other hosts instead of silently passing through an inline `if` — and + * so the opposite branch (e.g. POSIX rejecting drive paths) is always + * asserted on the matching host. + */ + +/** True when running on Windows (drive letters / UNC path semantics apply). */ +export const isWin32 = process.platform === 'win32' + +/** True on every non-Windows host (POSIX path semantics). */ +export const isPosix = !isWin32 From 0052061a72421903b14bbe0933cb7db83ccb2b9b Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 16 Aug 2026 22:28:40 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(html):=20UNC=20=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E4=BF=A1=E5=8F=B7=E7=BA=B3=E5=85=A5=E6=96=87=E4=BB=B6=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E6=9C=AC=E8=BA=AB=EF=BC=88=E5=9B=9E=E5=BA=94=20Codex?= =?UTF-8?q?=20P2=20review=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cwd 未水合或为正斜杠形式时,仅凭 scope.cwd 会漏掉反斜杠 UNC 路径的 '//' 标记,win32 上预览被 isWithin 拒绝(403)。htmlUrl 现在同时以 isWindowsStylePath(path) 判定:明确的反斜杠 UNC 路径始终保留标记; '//' 正斜杠路径仍只由 cwd 形态决定(POSIX 不受影响)。 --- src/client/api.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/client/api.ts b/src/client/api.ts index 6a56bff..b1e72d1 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -215,10 +215,15 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string { /** * 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). + * the same route with the session scope intact. The platform guard for the + * UNC marker comes from the session cwd's shape (the browser OS may differ + * from the host OS, so no client-side platform detection is used), extended + * by the file path itself: an unambiguous backslash UNC path keeps its '//' + * marker even while the cwd is still hydrating or arrives in forward-slash + * form. Forward-slash `//` paths stay ambiguous without a Windows-style cwd, + * so they are deliberately NOT marked on their own — a POSIX session keeps + * them as ordinary absolute paths. */ export function htmlUrl(scope: SessionScope, path: string): string { - return encodeHtmlUrl(scope.sessionId, path, isWindowsStylePath(scope.cwd ?? '')) + return encodeHtmlUrl(scope.sessionId, path, isWindowsStylePath(scope.cwd ?? '') || isWindowsStylePath(path)) } From 9652bbda5981cc7271a61d54bb322cd60ed9e189 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 16 Aug 2026 23:17:03 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(html):=20=E6=AD=A3=E6=96=9C=E6=9D=A0=20?= =?UTF-8?q?UNC=20=E8=B7=AF=E5=BE=84=E5=9C=A8=E5=8C=B9=E9=85=8D=E6=AD=A3?= =?UTF-8?q?=E6=96=9C=E6=9D=A0=20cwd=20=E6=97=B6=E4=B9=9F=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=EF=BC=88=E5=9B=9E=E5=BA=94=20Codex=20P2=20#2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当 cwd 与产出路径都是 //server/share/... 形式时,此前两个 isWindowsStylePath 都为 false 而漏掉 '//' 标记,win32 解码后落在当前 盘符被 isWithin 拒绝。现按 review 建议:'//' 路径仅在其 '//' cwd 匹配时 视为 UNC;POSIX 会话的 '//' 绝对路径仍保持无标记。 --- src/client/api.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/client/api.ts b/src/client/api.ts index b1e72d1..194473b 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -212,6 +212,13 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string { return `/sidebar/file?${params.toString()}` } +/** A forward-slash UNC share (`//server/share/...`) — ambiguous with a POSIX + * absolute path, so it only counts as Windows-style when the session cwd is + * itself a forward-slash UNC (see htmlUrl). */ +function isForwardSlashUnc(path: string): boolean { + return /^\/\/[^/]/.test(path) +} + /** * 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 @@ -220,10 +227,14 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string { * from the host OS, so no client-side platform detection is used), extended * by the file path itself: an unambiguous backslash UNC path keeps its '//' * marker even while the cwd is still hydrating or arrives in forward-slash - * form. Forward-slash `//` paths stay ambiguous without a Windows-style cwd, - * so they are deliberately NOT marked on their own — a POSIX session keeps - * them as ordinary absolute paths. + * form. A forward-slash `//` path is only marked when the cwd is itself a + * forward-slash UNC — a POSIX session keeps `//server/share/...` as an + * ordinary absolute path. */ export function htmlUrl(scope: SessionScope, path: string): string { - return encodeHtmlUrl(scope.sessionId, path, isWindowsStylePath(scope.cwd ?? '') || isWindowsStylePath(path)) + const cwd = scope.cwd ?? '' + const windowsPathStyle = isWindowsStylePath(cwd) + || isWindowsStylePath(path) + || (isForwardSlashUnc(path) && isForwardSlashUnc(cwd)) + return encodeHtmlUrl(scope.sessionId, path, windowsPathStyle) } From be95787c0056534897156977f72e93370ace79a5 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 16 Aug 2026 23:35:57 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20symlink=20=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E6=9C=89=E7=95=8C=E5=B9=B6=E5=8F=91=20+=20UNC=20=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E5=B9=B3=E5=8F=B0=E4=B8=AD=E6=80=A7=E5=8C=96=EF=BC=88?= =?UTF-8?q?=E5=9B=9E=E5=BA=94=20CR/=E8=87=AA=E5=AE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fs-tree: symlink 目标 stat 从串行改为 32 并发工作池(readdir 流关闭后统一 探测),symlink 密集/网络目标目录不再累加延迟;顺序与分类语义不变 - html-route: 解码不再重建反斜杠 UNC,统一重建为平台中性的 //server/share/... 前斜杠形态(win32/POSIX 均正确 resolve),从而删除 windowsPathStyle 平台信号 与 client isWindowsStylePath 助手;同时解决无 cwd 时正斜杠 UNC 的往返盲点 - 测试/文档同步;移除未使用的 isPosix --- ...2026-08-11-html-browser-builtins-design.md | 1 + src/client/api.ts | 26 +++-------- src/client/paths.ts | 15 +------ src/fs-tree.ts | 45 ++++++++++++++----- src/html-route.ts | 37 +++++++-------- tests/fs-tree-symlink.spec.ts | 24 ++++++++++ tests/fs-tree.spec.ts | 2 +- tests/html-route.spec.ts | 35 +++++++-------- tests/paths.spec.ts | 30 +++---------- tests/platform.ts | 3 -- 10 files changed, 104 insertions(+), 114 deletions(-) diff --git a/docs/plans/2026-08-11-html-browser-builtins-design.md b/docs/plans/2026-08-11-html-browser-builtins-design.md index 3626c33..17e4888 100644 --- a/docs/plans/2026-08-11-html-browser-builtins-design.md +++ b/docs/plans/2026-08-11-html-browser-builtins-design.md @@ -110,6 +110,7 @@ URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相 - 设计初期考虑过 srcdoc + `` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。 - `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。 - `nextBrowser` 在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的 `nextTerminal` 不同)。 +- **UNC 路径编码标记(PR #134 整合 #79/#115 时)**:`/sidebar/html` 对 `\\server\share\...` / `//server/share/...` 采用 sessionId 后的 `//` 标记,且解码统一重建为**前斜杠** `//server/share/...` 而非反斜杠——`node:path` 在 win32/POSIX 上分别解析为 `\\server\share\...` / `/server/share/...`,因此无需任何平台信号;原「400 双斜杠」约束改为「首个空段仅作 UNC 标记,其余空段仍 400」。 ## 8.1 追加需求(2026-08-11,v0.5.0 内) diff --git a/src/client/api.ts b/src/client/api.ts index 194473b..bfab3de 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -8,7 +8,6 @@ */ 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 { @@ -212,29 +211,14 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string { return `/sidebar/file?${params.toString()}` } -/** A forward-slash UNC share (`//server/share/...`) — ambiguous with a POSIX - * absolute path, so it only counts as Windows-style when the session cwd is - * itself a forward-slash UNC (see htmlUrl). */ -function isForwardSlashUnc(path: string): boolean { - return /^\/\/[^/]/.test(path) -} - /** * 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 platform guard for the - * UNC marker comes from the session cwd's shape (the browser OS may differ - * from the host OS, so no client-side platform detection is used), extended - * by the file path itself: an unambiguous backslash UNC path keeps its '//' - * marker even while the cwd is still hydrating or arrives in forward-slash - * form. A forward-slash `//` path is only marked when the cwd is itself a - * forward-slash UNC — a POSIX session keeps `//server/share/...` as an - * ordinary absolute path. + * the same route with the session scope intact. The UNC marker is + * platform-neutral — the host's requireAbsolute resolves the decoded + * forward-slash `//server/share/...` form on both win32 and POSIX — so no + * client-side platform signal is needed. */ export function htmlUrl(scope: SessionScope, path: string): string { - const cwd = scope.cwd ?? '' - const windowsPathStyle = isWindowsStylePath(cwd) - || isWindowsStylePath(path) - || (isForwardSlashUnc(path) && isForwardSlashUnc(cwd)) - return encodeHtmlUrl(scope.sessionId, path, windowsPathStyle) + return encodeHtmlUrl(scope.sessionId, path) } diff --git a/src/client/paths.ts b/src/client/paths.ts index 4247ab9..d87569b 100644 --- a/src/client/paths.ts +++ b/src/client/paths.ts @@ -5,25 +5,14 @@ * 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 + * host is the authority for path semantics, so this mirror deliberately + * accepts 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 diff --git a/src/fs-tree.ts b/src/fs-tree.ts index 67ddb0a..89cdc5f 100644 --- a/src/fs-tree.ts +++ b/src/fs-tree.ts @@ -60,30 +60,51 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise undefined) : undefined rows.push({ name: dirent.name, - path: fullPath, - isDir: info !== undefined ? info.isDirectory() : dirent.isDirectory(), - isSymlink, - broken: isSymlink && info === undefined, + path: join(path, dirent.name), + isDir: dirent.isDirectory(), + isSymlink: dirent.isSymbolicLink(), + broken: false, hidden: dirent.name.startsWith('.'), }) } } catch (error) { throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400) } + // Probe symlink targets AFTER the readdir stream closes, with bounded + // concurrency: a symlink-heavy level (UNC/network targets) would otherwise + // serialize up to maxEntries stat calls and stall the explorer. Non-symlink + // rows are skipped by the probe, so levels without links stay as cheap as + // before. + await probeSymlinkTargets(rows) rows.sort(compareEntries) return { path, entries: rows, truncated: overflow > 0 } } +/** How many symlink target stats run in flight during one level listing. */ +const SYMLINK_PROBE_CONCURRENCY = 32 + +/** Probe each symlink row's target once (bounded concurrency, order-preserving). */ +async function probeSymlinkTargets(rows: SidebarFsEntry[], concurrency = SYMLINK_PROBE_CONCURRENCY): Promise { + let next = 0 + const workers = Array.from({ length: Math.min(concurrency, rows.length) }, async () => { + for (;;) { + const index = next + next += 1 + if (index >= rows.length) return + const row = rows[index]! + if (!row.isSymlink) continue + // stat follows the chain; any failure (missing target, ELOOP, permission) + // leaves the row as a broken file-shaped link the editor refuses to read. + const info = await stat(row.path).catch(() => undefined) + row.isDir = info !== undefined ? info.isDirectory() : row.isDir + row.broken = info === undefined + } + }) + await Promise.all(workers) +} + /** The root row label of a listing: the last path segment (or the full path at the filesystem root). */ export function rootLabel(path: string): string { const base = basename(path) diff --git a/src/html-route.ts b/src/html-route.ts index 8b5b43b..1dfac34 100644 --- a/src/html-route.ts +++ b/src/html-route.ts @@ -13,16 +13,17 @@ * /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 + * UNC (\\server\share\... or //server/share/...): * → /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. + * The decoder rebuilds the marker as a forward-slash `//server/share/...` + * path. That form is intentionally platform-neutral: `node:path` resolves it + * to `\\server\share\...` on win32 and `/server/share/...` on POSIX, so the + * host's existing requireAbsolute + isWithin fence needs no platform signal + * (a leading `//` is a legal POSIX absolute path, so no data is lost on + * either platform). * * This module is intentionally dependency-free (no node imports, no wire * helpers) so the client bundle can import `encodeHtmlUrl` without tripping @@ -45,15 +46,9 @@ 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). - * @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) +/** Build the route URL for one absolute file path (client + tests). */ +export function encodeHtmlUrl(sessionId: string, path: string): string { + const unc = /^[\\/]{2}[^\\/]/.test(path) const segments = path.split(/[\\/]+/).filter(segment => segment !== '') return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}` } @@ -84,9 +79,9 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult { return { ok: false, status: 400, message: 'sessionId and file path are required' } } // An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits - // '//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. + // '//server/share/...' for 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 === '')) { @@ -94,9 +89,9 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult { } 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('\\')}` + // Rebuild the platform-neutral forward-slash form `//server/share/...`; + // requireAbsolute() resolves it to the platform's own UNC/POSIX spelling. + 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/...' diff --git a/tests/fs-tree-symlink.spec.ts b/tests/fs-tree-symlink.spec.ts index 8211a48..dfbeb30 100644 --- a/tests/fs-tree-symlink.spec.ts +++ b/tests/fs-tree-symlink.spec.ts @@ -86,4 +86,28 @@ describe.skipIf(!canSymlink)('fs-tree symlink listing', () => { rmSync(dir, { recursive: true, force: true }) } }) + + it('classifies every row correctly across a symlink-heavy level (bounded probe)', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-many-')) + mkdirSync(join(dir, 'real-dir')) + writeFileSync(join(dir, 'real-file.txt'), 'content') + try { + // More links than the probe concurrency cap exercises the worker pool; + // every directory link must still classify as a directory and every + // file link as a file (order is already checked by the sort assertion). + for (let index = 0; index < 48; index += 1) { + symlinkSync(join(dir, 'real-dir'), join(dir, `dir-link-${index}`)) + symlinkSync(join(dir, 'real-file.txt'), join(dir, `file-link-${index}`)) + } + const listing = await listDirectory(dir) + for (let index = 0; index < 48; index += 1) { + expect(listing.entries.find(entry => entry.name === `dir-link-${index}`)) + .toMatchObject({ isDir: true, isSymlink: true, broken: false }) + expect(listing.entries.find(entry => entry.name === `file-link-${index}`)) + .toMatchObject({ isDir: false, isSymlink: true, broken: false }) + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) }) diff --git a/tests/fs-tree.spec.ts b/tests/fs-tree.spec.ts index 7037ec7..2175e72 100644 --- a/tests/fs-tree.spec.ts +++ b/tests/fs-tree.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { resolve } from 'node:path' import { compareEntries, isWithin, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts' -import { isPosix, isWin32 } from './platform.ts' +import { isWin32 } from './platform.ts' describe('fs-tree', () => { it('sorts directories first, then names case-insensitively', () => { diff --git a/tests/html-route.spec.ts b/tests/html-route.spec.ts index 4da79ef..8bc8959 100644 --- a/tests/html-route.spec.ts +++ b/tests/html-route.spec.ts @@ -21,22 +21,19 @@ describe('encodeHtmlUrl', () => { .toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html') }) - it('encodes a Windows UNC path with the // marker only for Windows-style sessions', () => { - // Backslash UNC is unambiguous: the marker is emitted whenever the - // caller declares the session Windows-style. - expect(encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html', true)) + it('encodes a UNC path with the // marker (backslash and forward-slash forms)', () => { + expect(encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html')) .toBe('/sidebar/html/sess-1//server/share/proj/a.html') - // Forward-slash UNC needs the same marker on a Windows-style session. - expect(encodeHtmlUrl('sess-1', '//server/share/proj/a.html', true)) + expect(encodeHtmlUrl('sess-1', '//server/share/proj/a.html')) .toBe('/sidebar/html/sess-1//server/share/proj/a.html') }) - it('keeps a POSIX // path marker-free (the platform guard)', () => { - // A leading // is a legitimate POSIX absolute path: without the - // Windows-style flag it must NOT be marked as UNC, or the host would - // decode it back into a backslash form it then rejects. + it('keeps a POSIX // path marker-encoded (the marker is platform-neutral)', () => { + // The decoder rebuilds '//server/share/...', which node:path resolves to + // '/server/share/...' on POSIX and '\\server\share\...' on win32 — so the + // leading double slash round-trips without any platform signal. expect(encodeHtmlUrl('s-1', '//server/share/a.html')) - .toBe('/sidebar/html/s-1/server/share/a.html') + .toBe('/sidebar/html/s-1//server/share/a.html') }) it('percent-encodes special characters in segments', () => { @@ -74,19 +71,21 @@ describe('decodeHtmlUrl', () => { expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html') }) - it('decodes a UNC round-trip back to the network path', () => { - const url = encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html', true) + it('decodes a UNC round-trip back to the platform-neutral forward-slash form', () => { + const url = encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html') expect(decodeHtmlUrl(url)).toEqual({ ok: true, - ref: { sessionId: 'sess-1', path: '\\\\server\\share\\proj\\a.html' }, + ref: { sessionId: 'sess-1', path: '//server/share/proj/a.html' }, }) + // The host's requireAbsolute resolves that form per-platform: + // '\\server\share\proj\a.html' on win32, '/server/share/proj/a.html' on POSIX. }) - it('decodes a marker-free POSIX // path as a plain absolute path', () => { + it('round-trips a POSIX // path through the same marker', () => { const url = encodeHtmlUrl('s-1', '//server/share/a.html') expect(decodeHtmlUrl(url)).toEqual({ ok: true, - ref: { sessionId: 's-1', path: '/server/share/a.html' }, + ref: { sessionId: 's-1', path: '//server/share/a.html' }, }) }) @@ -167,12 +166,12 @@ describe('relative asset resolution stays in-route', () => { it('relative assets of a UNC document stay inside the same route', () => { // The WHATWG URL preserves the '//' marker during relative resolution, // so ./style.css lands back on the route with the UNC prefix intact. - const doc = `http://h${encodeHtmlUrl('s', '\\\\server\\share\\proj\\index.html', true)}` + const doc = `http://h${encodeHtmlUrl('s', '\\\\server\\share\\proj\\index.html')}` expect(new URL('./style.css', doc).pathname) .toBe('/sidebar/html/s//server/share/proj/style.css') expect(decodeHtmlUrl(new URL('./style.css', doc).pathname)).toEqual({ ok: true, - ref: { sessionId: 's', path: '\\\\server\\share\\proj\\style.css' }, + ref: { sessionId: 's', path: '//server/share/proj/style.css' }, }) }) }) diff --git a/tests/paths.spec.ts b/tests/paths.spec.ts index b053aee..cee6fec 100644 --- a/tests/paths.spec.ts +++ b/tests/paths.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { isAbsolutePath, isWindowsStylePath, relativeTo } from '../src/client/paths.ts' +import { isAbsolutePath, relativeTo } from '../src/client/paths.ts' import { resolveSidebarPath } from '../src/client/produced-files.ts' import { htmlUrl } from '../src/client/api.ts' @@ -56,33 +56,13 @@ describe('path helpers', () => { expect(isAbsolutePath('rel/x.ts')).toBe(false) }) - it('detects windows-style session paths (platform signal for the html route)', () => { - expect(isWindowsStylePath('C:\\work\\proj')).toBe(true) - expect(isWindowsStylePath('C:/work/proj')).toBe(true) - expect(isWindowsStylePath('\\\\server\\share\\proj')).toBe(true) - expect(isWindowsStylePath('//server/share/proj')).toBe(false) // ambiguous form needs the drive/backslash signal - expect(isWindowsStylePath('/Users/me/code')).toBe(false) - }) - - it('htmlUrl marks UNC paths from the file path itself (cwd-independent signal)', () => { - // cwd not hydrated yet: the unambiguous backslash UNC path must still - // keep its '//' marker, or win32 would decode /server/... against the - // current drive and the isWithin fence would reject the preview (403). + it('htmlUrl always marks UNC paths (platform-neutral marker)', () => { + // The marker is platform-neutral now: the host resolves the decoded + // '//server/share/...' form per-platform, so no cwd/OS signal is needed. expect(htmlUrl({ sessionId: 's' }, '\\\\server\\share\\proj\\a.html')) .toBe('/sidebar/html/s//server/share/proj/a.html') - // A forward-slash-form cwd (still Windows) does not mask the backslash path. - expect(htmlUrl({ sessionId: 's', cwd: '//server/share/proj' }, '\\\\server\\share\\proj\\a.html')) - .toBe('/sidebar/html/s//server/share/proj/a.html') - // Both cwd and path in the forward-slash UNC form: the '//' path matches - // the '//' cwd, so it is UNC and keeps the marker (win32 round-trips). - expect(htmlUrl({ sessionId: 's', cwd: '//server/share/proj' }, '//server/share/proj/a.html')) - .toBe('/sidebar/html/s//server/share/proj/a.html') - // A Windows drive cwd marks forward-slash UNC paths. - expect(htmlUrl({ sessionId: 's', cwd: 'C:\\work' }, '//server/share/proj/a.html')) - .toBe('/sidebar/html/s//server/share/proj/a.html') - // A POSIX session keeps forward-slash paths marker-free. expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '//server/share/a.html')) - .toBe('/sidebar/html/s/server/share/a.html') + .toBe('/sidebar/html/s//server/share/a.html') expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '/home/me/index.html')) .toBe('/sidebar/html/s/home/me/index.html') }) diff --git a/tests/platform.ts b/tests/platform.ts index c9633cf..ae65a87 100644 --- a/tests/platform.ts +++ b/tests/platform.ts @@ -8,6 +8,3 @@ /** True when running on Windows (drive letters / UNC path semantics apply). */ export const isWin32 = process.platform === 'win32' - -/** True on every non-Windows host (POSIX path semantics). */ -export const isPosix = !isWin32