win32.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. /**
  2. * Unit tests for the Windows durable namespace helper with a mocked kernel32
  3. * binding. The real JSONL suite exercises the helper on native Windows; these
  4. * tests keep the Win32 error mapping and race handling covered on every host.
  5. */
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
  8. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. const MOVEFILE_WRITE_THROUGH = 0x00000008
  12. const ERROR_FILE_NOT_FOUND = 2
  13. const ERROR_PATH_NOT_FOUND = 3
  14. const ERROR_ACCESS_DENIED = 5
  15. const ERROR_NOT_SAME_DEVICE = 17
  16. const ERROR_FILE_EXISTS = 80
  17. const ERROR_INVALID_NAME = 123
  18. const ERROR_ALREADY_EXISTS = 183
  19. type MoveFileExW = (existing: string, replacement: string, flags: number, setLastError: (code: number) => void) => number
  20. const roots: string[] = []
  21. function stripNamespace(path: string): string {
  22. if (path.startsWith('\\\\?\\UNC\\')) return `\\\\${path.slice('\\\\?\\UNC\\'.length)}`
  23. if (path.startsWith('\\\\?\\')) return path.slice('\\\\?\\'.length)
  24. return path
  25. }
  26. async function tempRoot(): Promise<string> {
  27. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-win32-'))
  28. roots.push(dir)
  29. return dir
  30. }
  31. async function importWithMove(moveFileExW: MoveFileExW): Promise<typeof import('../src/win32.ts')> {
  32. vi.resetModules()
  33. vi.doMock('koffi', () => {
  34. let lastError = 0
  35. const setLastError = (code: number): void => { lastError = code }
  36. const move: MoveFileExW = (existing, replacement, flags, setError) => {
  37. const ok = moveFileExW(existing, replacement, flags, setError)
  38. lastError = ok === 0 ? lastError : 0
  39. return ok
  40. }
  41. return {
  42. default: {
  43. load: () => ({
  44. func: (_convention: string, name: string, result: string) => {
  45. if (name === 'MoveFileExW') return (existing: string, replacement: string, flags: number) => {
  46. expect(result).toBe('int')
  47. const ok = move(existing, replacement, flags, setLastError)
  48. return ok
  49. }
  50. return () => lastError
  51. },
  52. }),
  53. },
  54. }
  55. })
  56. return import('../src/win32.ts')
  57. }
  58. async function importWithError(code: number): Promise<typeof import('../src/win32.ts')> {
  59. vi.resetModules()
  60. vi.doMock('koffi', () => ({
  61. default: {
  62. load: () => ({
  63. func: (_convention: string, name: string) => {
  64. if (name === 'MoveFileExW') return () => 0
  65. return () => code
  66. },
  67. }),
  68. },
  69. }))
  70. return import('../src/win32.ts')
  71. }
  72. async function importWithFilesystemMove(): Promise<typeof import('../src/win32.ts')> {
  73. return importWithMove((existing, replacement, flags, setLastError) => {
  74. expect(flags).toBe(MOVEFILE_WRITE_THROUGH)
  75. const from = stripNamespace(existing)
  76. const to = stripNamespace(replacement)
  77. if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 }
  78. if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return 0 }
  79. renameSync(from, to)
  80. return 1
  81. })
  82. }
  83. afterEach(async () => {
  84. vi.doUnmock('koffi')
  85. vi.doUnmock('node:fs/promises')
  86. vi.doUnmock('node:path')
  87. vi.resetModules()
  88. for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
  89. })
  90. describe('Windows durable namespace helpers', () => {
  91. it('keeps drive-root probes native while namespacing descendants', async () => {
  92. const probes: string[] = []
  93. vi.resetModules()
  94. vi.doMock('node:fs/promises', async (importOriginal) => {
  95. const actual = await importOriginal<typeof import('node:fs/promises')>()
  96. return {
  97. ...actual,
  98. stat: async (path: string) => {
  99. probes.push(path)
  100. return { isDirectory: () => true }
  101. },
  102. }
  103. })
  104. vi.doMock('node:path', async (importOriginal) => {
  105. const actual = await importOriginal<typeof import('node:path')>()
  106. return {
  107. ...actual,
  108. join: (...paths: string[]) => actual.win32.join(...paths),
  109. parse: (path: string) => actual.win32.parse(path),
  110. resolve: (...paths: string[]) => actual.win32.resolve(...paths),
  111. toNamespacedPath: (path: string) => actual.win32.toNamespacedPath(path),
  112. }
  113. })
  114. const { ensureDurableDirectoryWin32 } = await import('../src/win32.ts')
  115. await ensureDurableDirectoryWin32('C:\\existing')
  116. expect(probes).toEqual(['C:\\', '\\\\?\\C:\\existing'])
  117. })
  118. it('publishes a new file with write-through MoveFileExW semantics', async () => {
  119. const { publishNewFileWin32 } = await importWithFilesystemMove()
  120. const root = await tempRoot()
  121. const tmp = join(root, 'log.tmp')
  122. const final = join(root, 'log.jsonl')
  123. await writeFile(tmp, 'content')
  124. await publishNewFileWin32(tmp, final)
  125. expect(existsSync(tmp)).toBe(false)
  126. expect(readFileSync(final, 'utf8')).toBe('content')
  127. })
  128. it('maps Win32 publish failures to Node-style errno codes', async () => {
  129. const cases = [
  130. [ERROR_FILE_NOT_FOUND, 'ENOENT'],
  131. [ERROR_PATH_NOT_FOUND, 'ENOENT'],
  132. [ERROR_ACCESS_DENIED, 'EACCES'],
  133. [ERROR_NOT_SAME_DEVICE, 'EXDEV'],
  134. [ERROR_FILE_EXISTS, 'EEXIST'],
  135. [ERROR_ALREADY_EXISTS, 'EEXIST'],
  136. [ERROR_INVALID_NAME, 'EINVAL'],
  137. [9999, 'EIO'],
  138. ] as const
  139. for (const [win32Code, code] of cases) {
  140. const { publishNewFileWin32 } = await importWithError(win32Code)
  141. await expect(publishNewFileWin32('from', 'to')).rejects.toMatchObject({ code, win32Code, path: 'from', dest: 'to' })
  142. }
  143. })
  144. it('creates missing directories through staging siblings and tolerates an already-created race', async () => {
  145. const root = await tempRoot()
  146. const raced = join(root, 'raced')
  147. const { ensureDurableDirectoryWin32 } = await importWithMove((existing, replacement, flags, setLastError) => {
  148. expect(flags).toBe(MOVEFILE_WRITE_THROUGH)
  149. const from = stripNamespace(existing)
  150. const to = stripNamespace(replacement)
  151. if (to === raced) {
  152. mkdirSync(to)
  153. setLastError(ERROR_ALREADY_EXISTS)
  154. return 0
  155. }
  156. if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 }
  157. if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return 0 }
  158. renameSync(from, to)
  159. return 1
  160. })
  161. await ensureDurableDirectoryWin32(join(root, 'a', 'b'))
  162. expect(existsSync(join(root, 'a', 'b'))).toBe(true)
  163. await ensureDurableDirectoryWin32(join(root, 'a', 'b'))
  164. await ensureDurableDirectoryWin32(raced)
  165. expect(existsSync(raced)).toBe(true)
  166. })
  167. it('keeps staging names valid for a maximum-length target component', async () => {
  168. const { ensureDurableDirectoryWin32 } = await importWithFilesystemMove()
  169. const root = await tempRoot()
  170. const target = join(root, 'x'.repeat(255))
  171. await ensureDurableDirectoryWin32(target)
  172. expect(existsSync(target)).toBe(true)
  173. })
  174. it('surfaces directory publication failures other than an existing-target race', async () => {
  175. const { ensureDurableDirectoryWin32 } = await importWithError(ERROR_ACCESS_DENIED)
  176. const root = await tempRoot()
  177. await expect(ensureDurableDirectoryWin32(join(root, 'denied'))).rejects.toMatchObject({ code: 'EACCES' })
  178. })
  179. it('rejects a non-directory component instead of treating it as missing', async () => {
  180. const { ensureDurableDirectoryWin32 } = await importWithFilesystemMove()
  181. const root = await tempRoot()
  182. const blocked = join(root, 'blocked')
  183. writeFileSync(blocked, 'x')
  184. await expect(ensureDurableDirectoryWin32(join(blocked, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' })
  185. })
  186. })
  187. async function importWithLock(bindings: {
  188. createSemaphoreW?: (name: string, initial: number, maximum: number) => number
  189. waitResult?: number
  190. releaseSemaphore?: (handle: number) => number
  191. closeHandle?: (handle: number) => number
  192. lastError?: number
  193. }): Promise<typeof import('../src/win32.ts')> {
  194. vi.resetModules()
  195. vi.doMock('koffi', () => ({
  196. default: {
  197. load: () => ({
  198. func: (_convention: string, name: string) => {
  199. if (name === 'CreateSemaphoreW') {
  200. return (_security: null, initial: number, maximum: number, semName: string) =>
  201. (bindings.createSemaphoreW ?? (() => 7))(semName, initial, maximum)
  202. }
  203. if (name === 'WaitForSingleObject') return () => bindings.waitResult ?? 0
  204. if (name === 'ReleaseSemaphore') return bindings.releaseSemaphore ?? (() => 1)
  205. if (name === 'CloseHandle') return bindings.closeHandle ?? (() => 1)
  206. if (name === 'MoveFileExW') return () => 1
  207. return () => bindings.lastError ?? 0 // GetLastError
  208. },
  209. }),
  210. },
  211. }))
  212. return import('../src/win32.ts')
  213. }
  214. describe('Windows write-lock semaphore', () => {
  215. it('acquires a path-derived named semaphore with a zero-timeout wait', async () => {
  216. const created: Array<{ name: string; initial: number; maximum: number }> = []
  217. const { acquireLockHandleWin32 } = await importWithLock({
  218. createSemaphoreW: (name, initial, maximum) => {
  219. created.push({ name, initial, maximum })
  220. return 7
  221. },
  222. })
  223. await expect(acquireLockHandleWin32('C:\\s\\session.lock')).resolves.toBe(7)
  224. expect(created).toHaveLength(1)
  225. // Count-1 semaphore in the login-session namespace, named by path hash:
  226. // no filesystem footprint, and case-insensitive like Windows paths.
  227. expect(created[0]).toMatchObject({ initial: 1, maximum: 1 })
  228. expect(created[0]?.name).toMatch(/^Local\\dsh-session-lock-[0-9a-f]{64}$/)
  229. const upper = await importWithLock({ createSemaphoreW: (name) => { created.push({ name, initial: 1, maximum: 1 }); return 7 } })
  230. await upper.acquireLockHandleWin32('C:\\S\\SESSION.LOCK')
  231. expect(created[1]?.name).toBe(created[0]?.name)
  232. })
  233. it('maps a held semaphore (wait timeout) to EBUSY and closes the probe handle', async () => {
  234. const closed: number[] = []
  235. const { acquireLockHandleWin32 } = await importWithLock({
  236. waitResult: 0x102,
  237. closeHandle: (handle) => { closed.push(handle); return 1 },
  238. })
  239. await expect(acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EBUSY' })
  240. expect(closed).toEqual([7])
  241. })
  242. it('surfaces create and wait failures with Win32 codes', async () => {
  243. const createFailed = await importWithLock({ createSemaphoreW: () => 0, lastError: 5 })
  244. await expect(createFailed.acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 })
  245. const waitFailed = await importWithLock({ waitResult: 0xffffffff, lastError: 5 })
  246. await expect(waitFailed.acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 })
  247. })
  248. it('releases by restoring the count and closing, surfacing a failed release', async () => {
  249. const order: string[] = []
  250. const working = await importWithLock({
  251. releaseSemaphore: (handle) => { order.push(`release:${handle}`); return 1 },
  252. closeHandle: (handle) => { order.push(`close:${handle}`); return 1 },
  253. })
  254. await working.releaseLockHandleWin32(7)
  255. expect(order).toEqual(['release:7', 'close:7'])
  256. const failing = await importWithLock({ releaseSemaphore: () => 0, lastError: 5 })
  257. await expect(failing.releaseLockHandleWin32(9)).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 })
  258. })
  259. })