- 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
112 lines
5.2 KiB
TypeScript
112 lines
5.2 KiB
TypeScript
/**
|
|
* Pure URL vocabulary of the /sidebar/html route (HTML previewer).
|
|
*
|
|
* Why path-encoded parameters instead of a query string: the previewed
|
|
* page resolves its relative assets (./style.css, img/x.png) against the
|
|
* document URL, and the WHATWG URL algorithm DROPS the query of a
|
|
* path-relative reference — `/sidebar/html?a=1&path=/a/b/` + `./style.css`
|
|
* would lose the session scope and the route would reject the asset.
|
|
* Encoding everything into the URL path keeps relative resolution inside
|
|
* the same route with every request self-contained:
|
|
*
|
|
* /sidebar/html/<sessionId>/<absolute-path segments, encodeURIComponent'd>
|
|
* /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
|
|
* the build-time purity gate; the host converts decode failures into
|
|
* SidebarError responses at the route boundary.
|
|
*/
|
|
|
|
/** One decoded route reference. */
|
|
export interface HtmlRouteRef {
|
|
sessionId: string
|
|
/** Absolute file path (leading slash; Windows drives keep their colon). */
|
|
path: string
|
|
}
|
|
|
|
/** Decode outcome: the reference, or a client-error description. */
|
|
export type HtmlDecodeResult =
|
|
| { ok: true; ref: HtmlRouteRef }
|
|
| { ok: false; status: 400 | 404; message: string }
|
|
|
|
/** 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)
|
|
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
|
|
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 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 === '') {
|
|
return { ok: false, status: 400, message: 'invalid html route path' }
|
|
}
|
|
let segments: string[]
|
|
try {
|
|
segments = rest.split('/').map(segment => decodeURIComponent(segment))
|
|
} catch {
|
|
return { ok: false, status: 400, message: 'malformed URL encoding' }
|
|
}
|
|
const [sessionId, ...pathSegments] = segments
|
|
if (sessionId === undefined || sessionId === '') {
|
|
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.
|
|
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 } }
|
|
}
|