fix: symlink 探测有界并发 + UNC 标记平台中性化(回应 CR/自审)
- fs-tree: symlink 目标 stat 从串行改为 32 并发工作池(readdir 流关闭后统一 探测),symlink 密集/网络目标目录不再累加延迟;顺序与分类语义不变 - html-route: 解码不再重建反斜杠 UNC,统一重建为平台中性的 //server/share/... 前斜杠形态(win32/POSIX 均正确 resolve),从而删除 windowsPathStyle 平台信号 与 client isWindowsStylePath 助手;同时解决无 cwd 时正斜杠 UNC 的往返盲点 - 测试/文档同步;移除未使用的 isPosix
This commit is contained in:
+5
-21
@@ -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)
|
||||
}
|
||||
|
||||
+2
-13
@@ -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
|
||||
|
||||
+33
-12
@@ -60,30 +60,51 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
|
||||
}
|
||||
// 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,
|
||||
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<void> {
|
||||
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)
|
||||
|
||||
+16
-21
@@ -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
|
||||
// '<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.
|
||||
// '<sid>//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/...'
|
||||
|
||||
Reference in New Issue
Block a user