From be95787c0056534897156977f72e93370ace79a5 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 16 Aug 2026 23:35:57 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20symlink=20=E6=8E=A2=E6=B5=8B=E6=9C=89?= =?UTF-8?q?=E7=95=8C=E5=B9=B6=E5=8F=91=20+=20UNC=20=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E4=B8=AD=E6=80=A7=E5=8C=96=EF=BC=88=E5=9B=9E?= =?UTF-8?q?=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