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

- fs-tree: requireAbsolute 改用 path.isAbsolute(win32 UNC 放行、C:foo 仍拒);
  目录列表仅对 symlink 行 stat 一次,暴露 isSymlink/broken,目录软链接可展开
- html-route: UNC 路径经 '//' 标记编码往返;标记仅在 Windows 风格会话启用,
  POSIX '//' 绝对路径不再被误判(平台守卫)
- client: isAbsolutePath/isWindowsStylePath 镜像 + resolveSidebarPath 识别 UNC,
  htmlUrl 以会话 cwd 形态传递平台信号;FsEntry 增加 isSymlink/broken
- explorer: 软链接行链接徽标、悬空链接标红 + tooltip(issue #65)
- 测试: tests/platform.ts 守卫助手;win32 专属用例改为 skipIf 可见跳过,
  补 POSIX 反向分支与 UNC 矩阵;新增 fs-tree-symlink.spec
This commit is contained in:
Menghuan1918
2026-08-16 23:24:43 +08:00
parent bb3b40ff15
commit db189e3852
16 changed files with 424 additions and 62 deletions
+89
View File
@@ -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 })
}
})
})
+57 -17
View File
@@ -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)
})
})
+56 -3
View File
@@ -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' },
})
})
})
+55 -1
View File
@@ -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')
})
})
+13
View File
@@ -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