canonical-path.spec.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. /**
  2. * 路径 canonicalization 与 membership(dsh 运行时对齐批)。
  3. *
  4. * 覆盖:盘符大小写、相对路径绝对化、不存在深路径(最长存在祖先+尾段)、
  5. * junction/symlink 别名(修复前误判逃逸)、`\\?\` 长路径前缀、逃逸仍拒。
  6. */
  7. import { afterAll, describe, expect, it } from 'vitest'
  8. import * as fs from 'node:fs'
  9. import * as os from 'node:os'
  10. import * as path from 'node:path'
  11. import { canonicalizePath, isInsidePath, resolveInsideBook, stripDevicePrefix } from '../src/index'
  12. const roots: string[] = []
  13. function mkDir(prefix: string): string {
  14. const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
  15. roots.push(dir)
  16. return dir
  17. }
  18. afterAll(() => {
  19. for (const r of roots) {
  20. try { fs.rmSync(r, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }) } catch { /* Windows 句柄延迟 */ }
  21. }
  22. })
  23. const IS_WIN = process.platform === 'win32'
  24. describe('canonicalizePath', () => {
  25. it('相对路径绝对化且与 resolve 一致', () => {
  26. const c = canonicalizePath('some/relative/path')
  27. expect(path.isAbsolute(c)).toBe(true)
  28. })
  29. it('不存在深路径:取最长存在祖先 realpath 并拼回尾段', () => {
  30. const base = mkDir('webnovel-can-')
  31. const deep = path.join(base, '不存在的目录', '更深', '草稿.md')
  32. const c = canonicalizePath(deep)
  33. expect(c.startsWith(canonicalizePath(base))).toBe(true)
  34. expect(c.endsWith(path.join('不存在的目录', '更深', '草稿.md'))).toBe(true)
  35. })
  36. it('stripDevicePrefix:剥 \\\\?\\ 与 \\\\?\\UNC\\ 前缀', () => {
  37. if (!IS_WIN) {
  38. expect(stripDevicePrefix('/a/b')).toBe('/a/b')
  39. expect(stripDevicePrefix('\\\\?\\C:\\a\\b')).toBe('\\\\?\\C:\\a\\b')
  40. return
  41. }
  42. expect(stripDevicePrefix('\\\\?\\C:\\a\\b')).toBe('C:\\a\\b')
  43. expect(stripDevicePrefix('\\\\?\\UNC\\srv\\share\\a')).toBe('\\\\srv\\share\\a')
  44. expect(stripDevicePrefix('C:\\a\\b')).toBe('C:\\a\\b')
  45. })
  46. it('盘符大小写归一(win32)', () => {
  47. if (!IS_WIN) return
  48. const lower = 'c:\\'
  49. const c = canonicalizePath(lower)
  50. expect(c.startsWith('C:')).toBe(true)
  51. })
  52. it('junction/symlink 别名解析到真实目标', () => {
  53. const base = mkDir('webnovel-can-alias-')
  54. const realDir = path.join(base, '真实目录')
  55. fs.mkdirSync(realDir, { recursive: true })
  56. const linkPath = path.join(base, '别名目录')
  57. try {
  58. fs.symlinkSync(realDir, linkPath, IS_WIN ? 'junction' : 'dir')
  59. } catch {
  60. // 环境不支持符号链接:跳过本例(权限不足的 CI 等)
  61. return
  62. }
  63. const c = canonicalizePath(path.join(linkPath, '子文件.md'))
  64. expect(c.startsWith(canonicalizePath(realDir))).toBe(true)
  65. expect(c.includes('别名目录')).toBe(false)
  66. })
  67. })
  68. describe('isInsidePath', () => {
  69. it('严格内含判成立,根自身不算内含,外部位拒', () => {
  70. const base = mkDir('webnovel-can-inside-')
  71. const child = path.join(base, '子')
  72. fs.mkdirSync(child, { recursive: true })
  73. expect(isInsidePath(base, child)).toBe(true)
  74. expect(isInsidePath(base, path.join(child, '再深', 'x.md'))).toBe(true)
  75. expect(isInsidePath(base, base)).toBe(false)
  76. expect(isInsidePath(base, path.dirname(base))).toBe(false)
  77. })
  78. it('别名路径(child)仍判内含——修复前 junction 别名误判逃逸', () => {
  79. const base = mkDir('webnovel-can-gate-')
  80. const realDir = path.join(base, '书仓')
  81. fs.mkdirSync(path.join(realDir, '草稿区'), { recursive: true })
  82. const alias = path.join(base, '书仓别名')
  83. try {
  84. fs.symlinkSync(realDir, alias, IS_WIN ? 'junction' : 'dir')
  85. } catch {
  86. return
  87. }
  88. expect(isInsidePath(realDir, path.join(alias, '草稿区', '稿.md'))).toBe(true)
  89. // resolveInsideBook 同口径放行(修复前误判「路径逃逸书仓根」)
  90. const r = resolveInsideBook(realDir, path.join(alias, '草稿区', '稿.md'))
  91. expect(r.ok).toBe(true)
  92. if (r.ok) expect(r.relPath.replace(/\\/g, '/')).toBe('草稿区/稿.md')
  93. })
  94. it('盘符大小写与 \\\\?\\ 形态不影响 membership(win32)', () => {
  95. if (!IS_WIN) return
  96. const base = mkDir('webnovel-can-drive-')
  97. const child = path.join(base, '子')
  98. fs.mkdirSync(child, { recursive: true })
  99. const drive = child.slice(0, 1)
  100. expect(isInsidePath(base, `${drive.toLowerCase()}${child.slice(1)}`)).toBe(true)
  101. expect(isInsidePath(base, `\\\\?\\${child}`)).toBe(true)
  102. })
  103. it('resolveInsideBook:真逃逸仍拒', () => {
  104. const base = mkDir('webnovel-can-esc-')
  105. const book = path.join(base, '书仓')
  106. fs.mkdirSync(book, { recursive: true })
  107. expect(resolveInsideBook(book, path.join(base, '外面.md')).ok).toBe(false)
  108. expect(resolveInsideBook(book, path.join(book, '..', '逃逸.md')).ok).toBe(false)
  109. expect(resolveInsideBook(book, book).ok).toBe(false)
  110. })
  111. })