fsio.spec.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. /**
  2. * Cordis-free tests for the raw local-filesystem I/O: path resolution, probe,
  3. * whole-file/streamed text reads, binary/UTF-8 rejection, atomic-write temp
  4. * safety, literal edit matching, and line-ending handling. Line WINDOWING is
  5. * policy and lives in `dsh-fs-policy`, so it is not tested here.
  6. */
  7. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  8. import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { createServer } from 'node:net'
  12. import {
  13. applyLiteralEdit,
  14. listDirectory,
  15. probe,
  16. probeNoFollow,
  17. readForEdit,
  18. readWholeText,
  19. resolveLocalTarget,
  20. restoreLineEndings,
  21. streamWholeText,
  22. writeFileAtomic,
  23. } from '../src/fsio.ts'
  24. import type { LocalTarget } from '../src/fsio.ts'
  25. import { copyFileDaclWin32, readFileDaclWin32 } from '../src/win32.ts'
  26. import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
  27. let dir: string
  28. beforeEach(async () => {
  29. dir = await mkdtemp(join(tmpdir(), 'dsh-fsio-'))
  30. })
  31. afterEach(async () => {
  32. await rm(dir, { recursive: true, force: true })
  33. })
  34. const localTarget = (path: string): LocalTarget => ({ displayPath: path, targetKey: FsTargetKey(path) })
  35. async function collect(chunks: AsyncIterable<string>): Promise<string> {
  36. let out = ''
  37. for await (const chunk of chunks) out += chunk
  38. return out
  39. }
  40. describe('resolveLocalTarget', () => {
  41. it('resolves a relative path from cwd and realpaths it', async () => {
  42. const file = join(dir, 'a.txt')
  43. await writeFile(file, 'hi')
  44. const target = await resolveLocalTarget(dir, 'a.txt')
  45. expect(target.displayPath).toBe(file)
  46. expect(target.targetKey).toBe(await realpath(file))
  47. })
  48. it('uses the realpathed parent + basename when the file does not exist (stable across create)', async () => {
  49. const target = await resolveLocalTarget(dir, 'missing.txt')
  50. expect(target.targetKey).toBe(join(await realpath(dir), 'missing.txt'))
  51. })
  52. it('two paths to the same file via a symlink share one targetKey', async () => {
  53. const real = join(dir, 'real.txt')
  54. await writeFile(real, 'hi')
  55. const link = join(dir, 'link.txt')
  56. await symlink(real, link)
  57. const viaReal = await resolveLocalTarget(dir, 'real.txt')
  58. const viaLink = await resolveLocalTarget(dir, 'link.txt')
  59. expect(viaLink.targetKey).toBe(viaReal.targetKey)
  60. expect(viaLink.displayPath).toBe(link)
  61. })
  62. it('realpaths the nearest existing ancestor when intermediate dirs are missing', async () => {
  63. const target = await resolveLocalTarget(dir, 'no-such-dir/child.txt')
  64. expect(target.targetKey).toBe(join(await realpath(dir), 'no-such-dir', 'child.txt'))
  65. })
  66. it('keeps the key stable across create when an ancestor is a symlink', async () => {
  67. // A symlinked workspace root with a not-yet-created subdirectory: the
  68. // pre-create key (via the symlink, missing parent) must equal the
  69. // post-create key (file exists, realpathed) so observed-state survives.
  70. const realRoot = join(dir, 'real-root')
  71. await mkdir(realRoot)
  72. const linkRoot = join(dir, 'link-root')
  73. await symlink(realRoot, linkRoot)
  74. const before = await resolveLocalTarget(linkRoot, 'sub/file.txt')
  75. await mkdir(join(realRoot, 'sub'), { recursive: true })
  76. await writeFile(join(realRoot, 'sub', 'file.txt'), 'hi') // create through the real path
  77. const after = await resolveLocalTarget(linkRoot, 'sub/file.txt')
  78. expect(before.targetKey).toBe(after.targetKey)
  79. })
  80. it('rejects a blank path', async () => {
  81. await expect(resolveLocalTarget(dir, ' ')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
  82. })
  83. it('rejects a path whose ancestor is a file with a structured FsError (ENOTDIR)', async () => {
  84. // "afile" is a regular file, so "afile/child.txt" hits ENOTDIR on realpath;
  85. // the raw Node error must be translated into the FsError taxonomy so the tool
  86. // result keeps its { name, code } metadata.
  87. await writeFile(join(dir, 'afile'), 'i am a file')
  88. const err = await resolveLocalTarget(dir, 'afile/child.txt').then(() => undefined, (e: unknown) => e)
  89. expect(err).toBeInstanceOf(FsError)
  90. expect(err).toMatchObject({ code: 'FS_NOT_FOUND' })
  91. })
  92. })
  93. describe('probe', () => {
  94. it('returns null for a missing path and metadata for a file', async () => {
  95. expect(await probe(join(dir, 'nope'))).toBeNull()
  96. const file = join(dir, 'a.txt')
  97. await writeFile(file, 'hi')
  98. const info = await probe(file)
  99. expect(info?.type).toBe('file')
  100. expect(info?.size).toBe(2)
  101. expect(typeof info?.version).toBe('string')
  102. })
  103. it('reports a directory and a non-regular type', async () => {
  104. const sub = join(dir, 'sub')
  105. await mkdir(sub)
  106. expect((await probe(sub))?.type).toBe('directory')
  107. })
  108. it('reports a socket/special file as type "other"', async () => {
  109. const sockPath = join(dir, 'sock')
  110. const server = createServer()
  111. try {
  112. await new Promise<void>((resolve, reject) => {
  113. server.once('error', reject)
  114. server.listen(sockPath, () => { resolve() })
  115. })
  116. } catch (error: unknown) {
  117. // A restricted sandbox may forbid unix-domain sockets; that is an
  118. // environment limit, not a filesystem regression — skip rather than fail.
  119. const code = (error as NodeJS.ErrnoException).code
  120. if (code === 'EPERM' || code === 'EACCES' || code === 'ENOTSUP') return
  121. throw error
  122. }
  123. try {
  124. expect((await probe(sockPath))?.type).toBe('other')
  125. } finally {
  126. await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
  127. }
  128. })
  129. it('returns null when an ancestor path segment is a file (ENOTDIR), not a raw throw', async () => {
  130. await writeFile(join(dir, 'afile'), 'i am a file')
  131. expect(await probe(join(dir, 'afile', 'child.txt'))).toBeNull()
  132. })
  133. })
  134. describe('probeNoFollow', () => {
  135. it('reports symlinks without following them', async () => {
  136. const real = join(dir, 'real.txt')
  137. const link = join(dir, 'link.txt')
  138. await writeFile(real, 'hi')
  139. await symlink(real, link)
  140. expect((await probeNoFollow(real))?.type).toBe('file')
  141. const linkInfo = await probeNoFollow(link)
  142. expect(linkInfo?.type).toBe('symlink')
  143. expect(typeof linkInfo?.version).toBe('string')
  144. expect(linkInfo?.size).toBeGreaterThan(0)
  145. })
  146. it('returns null for a missing path or a file-valued ancestor path segment', async () => {
  147. expect(await probeNoFollow(join(dir, 'missing'))).toBeNull()
  148. await writeFile(join(dir, 'afile'), 'i am a file')
  149. expect(await probeNoFollow(join(dir, 'afile', 'child.txt'))).toBeNull()
  150. })
  151. })
  152. describe('listDirectory', () => {
  153. it('lists direct children in stable order without reading content', async () => {
  154. const root = join(dir, 'skills')
  155. await mkdir(join(root, 'dir-skill'), { recursive: true })
  156. await writeFile(join(root, 'zeta.md'), 'zeta')
  157. await writeFile(join(root, 'alpha.md'), 'alpha')
  158. await symlink(join(root, 'missing-target'), join(root, 'broken-link'))
  159. const entries = await listDirectory(localTarget(root))
  160. expect(entries.map(entry => [entry.name, entry.type])).toEqual([
  161. ['alpha.md', 'file'],
  162. ['broken-link', 'other'],
  163. ['dir-skill', 'directory'],
  164. ['zeta.md', 'file'],
  165. ])
  166. expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5)
  167. expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string')
  168. expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined()
  169. expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined()
  170. })
  171. it('derives child target keys from the listed parent identity', async () => {
  172. const realOne = join(dir, 'real-one')
  173. const realTwo = join(dir, 'real-two')
  174. const link = join(dir, 'link')
  175. await mkdir(realOne)
  176. await mkdir(realTwo)
  177. await writeFile(join(realOne, 'same.txt'), 'one')
  178. await writeFile(join(realTwo, 'same.txt'), 'different two')
  179. await symlink(realOne, link)
  180. const target = await resolveLocalTarget(dir, 'link')
  181. await unlink(link)
  182. await symlink(realTwo, link)
  183. const entries = await listDirectory(target)
  184. expect(entries).toHaveLength(1)
  185. expect(entries[0]).toMatchObject({
  186. name: 'same.txt',
  187. target: {
  188. displayPath: join(link, 'same.txt'),
  189. targetKey: await realpath(join(realOne, 'same.txt')),
  190. },
  191. size: 3,
  192. })
  193. })
  194. it('rejects missing, non-directory, and aborted listing requests', async () => {
  195. await expect(listDirectory(localTarget(join(dir, 'missing')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
  196. const file = join(dir, 'a.txt')
  197. await writeFile(file, 'hi')
  198. await expect(listDirectory(localTarget(file))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' })
  199. await expect(listDirectory(localTarget(dir), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
  200. })
  201. it('translates directory permission failures into FS_PERMISSION_DENIED', async () => {
  202. const root = join(dir, 'restricted')
  203. await mkdir(root)
  204. await chmod(root, 0o000)
  205. try {
  206. const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught)
  207. // Root-like environments may still be able to list mode-000 directories.
  208. if (error === undefined) return
  209. expect(error).toBeInstanceOf(FsError)
  210. expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' })
  211. } finally {
  212. await chmod(root, 0o700)
  213. }
  214. })
  215. it('translates preflight metadata IO failures into FS_IO_ERROR', async () => {
  216. const loop = join(dir, 'loop')
  217. await symlink(loop, loop)
  218. await expect(listDirectory(localTarget(loop))).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
  219. })
  220. it('translates child resolution failures into structured listing errors', async () => {
  221. const root = join(dir, 'listed')
  222. await mkdir(root)
  223. const loop = join(root, 'loop')
  224. await symlink(loop, loop)
  225. await expect(listDirectory(localTarget(root))).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
  226. })
  227. it('translates child permission failures into FS_PERMISSION_DENIED', async () => {
  228. const root = join(dir, 'listed')
  229. const protectedRoot = join(dir, 'protected')
  230. const secret = join(protectedRoot, 'secret')
  231. await mkdir(root)
  232. await mkdir(secret, { recursive: true })
  233. await symlink(secret, join(root, 'secret-link'))
  234. await chmod(protectedRoot, 0o000)
  235. try {
  236. const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught)
  237. // Root-like environments may still resolve through mode-000 directories.
  238. if (error === undefined) return
  239. expect(error).toBeInstanceOf(FsError)
  240. expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' })
  241. } finally {
  242. await chmod(protectedRoot, 0o700)
  243. }
  244. })
  245. })
  246. describe('readWholeText', () => {
  247. it('reads a small file', async () => {
  248. const file = join(dir, 'a.txt')
  249. await writeFile(file, 'one\ntwo\nthree')
  250. expect(await readWholeText(localTarget(file))).toBe('one\ntwo\nthree')
  251. })
  252. it('rejects a missing file and a directory', async () => {
  253. await expect(readWholeText(localTarget(join(dir, 'nope')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
  254. await expect(readWholeText(localTarget(dir))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
  255. })
  256. it('rejects binary and invalid UTF-8', async () => {
  257. await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
  258. await expect(readWholeText(localTarget(join(dir, 'bin')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  259. await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
  260. await expect(readWholeText(localTarget(join(dir, 'bad')))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  261. })
  262. it('honors a pre-aborted signal', async () => {
  263. const file = join(dir, 'a.txt')
  264. await writeFile(file, 'one')
  265. await expect(readWholeText(localTarget(file), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
  266. })
  267. it('passes a live (non-aborted) signal through', async () => {
  268. const file = join(dir, 'a.txt')
  269. await writeFile(file, 'one\ntwo')
  270. expect(await readWholeText(localTarget(file), new AbortController().signal)).toBe('one\ntwo')
  271. })
  272. it('translates a mid-read AbortError into FS_ABORTED', async () => {
  273. const file = join(dir, 'a.txt')
  274. await writeFile(file, 'one\ntwo')
  275. const ac = new AbortController()
  276. // Abort after the synchronous entry check but before readFile runs (the
  277. // stat await yields control back here), so readFile rejects AbortError.
  278. const pending = readWholeText(localTarget(file), ac.signal)
  279. ac.abort()
  280. await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
  281. })
  282. })
  283. describe('streamWholeText', () => {
  284. it('streams the whole file as decoded text', async () => {
  285. const file = join(dir, 'a.txt')
  286. await writeFile(file, 'one\ntwo\nthree')
  287. expect(await collect(streamWholeText(localTarget(file)))).toBe('one\ntwo\nthree')
  288. })
  289. it('streams a large multi-chunk file correctly', async () => {
  290. const file = join(dir, 'big.txt')
  291. const content = Array.from({ length: 50 }, (_, i) => `line ${i}: ${'x'.repeat(3000)}`).join('\n')
  292. await writeFile(file, content)
  293. expect(await collect(streamWholeText(localTarget(file)))).toBe(content)
  294. })
  295. it('rejects a missing file, directory, binary, and invalid UTF-8', async () => {
  296. await expect(collect(streamWholeText(localTarget(join(dir, 'nope'))))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
  297. await expect(collect(streamWholeText(localTarget(dir)))).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
  298. await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
  299. await expect(collect(streamWholeText(localTarget(join(dir, 'bin'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  300. await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
  301. await expect(collect(streamWholeText(localTarget(join(dir, 'bad'))))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  302. })
  303. it('honors a pre-aborted signal', async () => {
  304. const file = join(dir, 'a.txt')
  305. await writeFile(file, 'one')
  306. await expect(collect(streamWholeText(localTarget(file), AbortSignal.abort()))).rejects.toMatchObject({ code: 'FS_ABORTED' })
  307. })
  308. it('passes a live (non-aborted) signal through the stream', async () => {
  309. const file = join(dir, 'a.txt')
  310. await writeFile(file, 'one\ntwo')
  311. expect(await collect(streamWholeText(localTarget(file), new AbortController().signal))).toBe('one\ntwo')
  312. })
  313. it('translates a mid-stream abort into FS_ABORTED', async () => {
  314. // A multi-chunk file so the stream yields more than once; abort after the
  315. // first chunk and assert the structured code, not a raw AbortError.
  316. const file = join(dir, 'big.txt')
  317. await writeFile(file, 'x'.repeat(256 * 1024))
  318. const ac = new AbortController()
  319. const run = async (): Promise<void> => {
  320. let seen = 0
  321. for await (const _chunk of streamWholeText(localTarget(file), ac.signal)) {
  322. seen += 1
  323. if (seen === 1) ac.abort()
  324. }
  325. }
  326. await expect(run()).rejects.toMatchObject({ code: 'FS_ABORTED' })
  327. })
  328. })
  329. // Windows drives only the read-only attribute through `chmod` and reports synthetic `stat` mode
  330. // bits, so mode assertions are POSIX-only; native DACL preservation is asserted separately.
  331. const posixModes = process.platform !== 'win32'
  332. function daclAcePolicy(descriptor: Buffer): string[] {
  333. const daclOffset = descriptor.readUInt32LE(16)
  334. if (daclOffset === 0) return []
  335. const aceCount = descriptor.readUInt16LE(daclOffset + 4)
  336. const policy: string[] = []
  337. const seen = new Set<string>()
  338. let offset = daclOffset + 8
  339. for (let index = 0; index < aceCount; index++) {
  340. const size = descriptor.readUInt16LE(offset + 2)
  341. const ace = Buffer.from(descriptor.subarray(offset, offset + size))
  342. // INHERITED_ACE records provenance, not the entry's access policy.
  343. ace.writeUInt8(ace.readUInt8(1) & ~0x10, 1)
  344. const key = ace.toString('hex')
  345. if (!seen.has(key)) {
  346. seen.add(key)
  347. policy.push(key)
  348. }
  349. offset += size
  350. }
  351. return policy
  352. }
  353. describe('writeFileAtomic — temp-file safety', () => {
  354. it('writes through a private staging dir and owner-only temp file', async () => {
  355. const file = join(dir, 'a.txt')
  356. await writeFile(file, 'old')
  357. if (posixModes) await chmod(file, 0o640)
  358. let inspected = false
  359. await writeFileAtomic(file, 'hello', 0o640, undefined, {
  360. inspectTemp: async ({ stagingDir, tempPath }) => {
  361. inspected = true
  362. const [staging, temp] = await Promise.all([stat(stagingDir), stat(tempPath)])
  363. expect(staging.isDirectory()).toBe(true)
  364. expect(temp.isFile()).toBe(true)
  365. if (posixModes) {
  366. expect(staging.mode & 0o777).toBe(0o700)
  367. expect(temp.mode & 0o777).toBe(0o600)
  368. }
  369. },
  370. })
  371. expect(inspected).toBe(true)
  372. expect(await readFile(file, 'utf8')).toBe('hello')
  373. if (posixModes) expect((await stat(file)).mode & 0o777).toBe(0o640)
  374. expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
  375. })
  376. it.skipIf(process.platform !== 'win32')('protects staged content with the existing target DACL and preserves it after replacement', async () => {
  377. const file = join(dir, 'protected.txt')
  378. await writeFile(file, 'old')
  379. await copyFileDaclWin32(file, file)
  380. const expectedDacl = await readFileDaclWin32(file)
  381. await writeFileAtomic(file, 'new', (await stat(file)).mode, undefined, {
  382. inspectTemp: async ({ tempPath }) => {
  383. expect(await readFileDaclWin32(tempPath)).toEqual(expectedDacl)
  384. },
  385. })
  386. expect(await readFile(file, 'utf8')).toBe('new')
  387. expect(daclAcePolicy(await readFileDaclWin32(file))).toEqual(daclAcePolicy(expectedDacl))
  388. })
  389. it('copies a Windows target DACL before content and publishes through secure replacement', async () => {
  390. const file = join(dir, 'a.txt')
  391. await writeFile(file, 'old')
  392. const calls: string[] = []
  393. await writeFileAtomic(file, 'new', 0o666, undefined, {
  394. platform: 'win32',
  395. copyFileDacl: async (source, temp) => {
  396. calls.push(`copy:${source}`)
  397. expect(await readFile(temp, 'utf8')).toBe('')
  398. },
  399. replaceFile: async (target, temp) => {
  400. calls.push(`replace:${target}`)
  401. await rename(temp, target)
  402. },
  403. })
  404. expect(calls).toEqual([`copy:${file}`, `replace:${file}`])
  405. expect(await readFile(file, 'utf8')).toBe('new')
  406. })
  407. it('creates a new Windows file through directory inheritance without replacement calls', async () => {
  408. const file = join(dir, 'new.txt')
  409. const unexpected = async (): Promise<void> => { throw new Error('unexpected native replacement call') }
  410. await writeFileAtomic(file, 'new', undefined, undefined, {
  411. platform: 'win32',
  412. copyFileDacl: unexpected,
  413. replaceFile: unexpected,
  414. })
  415. expect(await readFile(file, 'utf8')).toBe('new')
  416. })
  417. it('recreates a vanished Windows target with the already-protected temp', async () => {
  418. const file = join(dir, 'a.txt')
  419. await writeFile(file, 'old')
  420. const missing = Object.assign(new Error('target vanished'), { code: 'ENOENT' })
  421. await writeFileAtomic(file, 'new', 0o666, undefined, {
  422. platform: 'win32',
  423. copyFileDacl: () => Promise.resolve(),
  424. replaceFile: async () => { throw missing },
  425. })
  426. expect(await readFile(file, 'utf8')).toBe('new')
  427. })
  428. it('surfaces a Windows secure-replacement failure and cleans the staging directory', async () => {
  429. const file = join(dir, 'a.txt')
  430. await writeFile(file, 'old')
  431. const denied = Object.assign(new Error('replace denied'), { code: 'EACCES' })
  432. await expect(writeFileAtomic(file, 'new', 0o666, undefined, {
  433. platform: 'win32',
  434. copyFileDacl: () => Promise.resolve(),
  435. replaceFile: async () => { throw denied },
  436. })).rejects.toBe(denied)
  437. expect(await readFile(file, 'utf8')).toBe('old')
  438. expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([])
  439. })
  440. it.skipIf(!posixModes)('creates new files owner-only by default', async () => {
  441. const file = join(dir, 'a.txt')
  442. await writeFileAtomic(file, 'hello', undefined, undefined)
  443. expect((await stat(file)).mode & 0o777).toBe(0o600)
  444. })
  445. it('opens staging paths exclusively — a pre-existing path is never clobbered', async () => {
  446. const file = join(dir, 'a.txt')
  447. const tempDirName = '.fixed-temp.tmpdir'
  448. await mkdir(join(dir, tempDirName))
  449. await writeFile(join(dir, tempDirName, 'PRECIOUS'), 'keep')
  450. await expect(
  451. writeFileAtomic(file, 'hello', undefined, undefined, { tempDirName: () => tempDirName }),
  452. ).rejects.toMatchObject({ code: 'EEXIST' })
  453. expect(await readFile(join(dir, tempDirName, 'PRECIOUS'), 'utf8')).toBe('keep')
  454. await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
  455. })
  456. it('creates parent directories as needed', async () => {
  457. const file = join(dir, 'nested', 'deep', 'a.txt')
  458. await writeFileAtomic(file, 'hi', undefined, undefined)
  459. expect(await readFile(file, 'utf8')).toBe('hi')
  460. })
  461. it('passes a live (non-aborted) signal through the write', async () => {
  462. const file = join(dir, 'a.txt')
  463. await writeFileAtomic(file, 'hi', undefined, new AbortController().signal)
  464. expect(await readFile(file, 'utf8')).toBe('hi')
  465. })
  466. it('aborts before writing when the signal is already aborted', async () => {
  467. const file = join(dir, 'a.txt')
  468. await expect(writeFileAtomic(file, 'hi', undefined, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
  469. await expect(stat(file)).rejects.toMatchObject({ code: 'ENOENT' })
  470. })
  471. it('cleans up the temp file when the final rename fails', async () => {
  472. const sub = join(dir, 'occupied')
  473. await mkdir(sub)
  474. await expect(writeFileAtomic(sub, 'hi', undefined, undefined)).rejects.toBeInstanceOf(Error)
  475. expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
  476. })
  477. })
  478. describe('applyLiteralEdit', () => {
  479. it('replaces a unique match', () => {
  480. expect(applyLiteralEdit('a b c', 'b', 'X', false, 'f')).toEqual({ content: 'a X c', replacements: 1 })
  481. })
  482. it('rejects zero matches', () => {
  483. expect(() => applyLiteralEdit('a b c', 'z', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
  484. })
  485. it('rejects an empty oldString without scanning forever', () => {
  486. expect(() => applyLiteralEdit('a b c', '', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_EDIT_NOT_FOUND' }))
  487. })
  488. it('rejects multiple matches without replaceAll', () => {
  489. expect(() => applyLiteralEdit('a a a', 'a', 'X', false, 'f')).toThrow(expect.objectContaining({ code: 'FS_AMBIGUOUS_EDIT' }))
  490. })
  491. it('replaces all matches with replaceAll', () => {
  492. expect(applyLiteralEdit('a a a', 'a', 'X', true, 'f')).toEqual({ content: 'X X X', replacements: 3 })
  493. })
  494. it('matches across normalized line endings', () => {
  495. expect(applyLiteralEdit('one\ntwo', 'one\ntwo', 'x', false, 'f').replacements).toBe(1)
  496. })
  497. })
  498. describe('readForEdit + restoreLineEndings', () => {
  499. it('round-trips CRLF: matches on LF, writes back CRLF', async () => {
  500. const file = join(dir, 'crlf.txt')
  501. await writeFile(file, 'one\r\ntwo\r\n')
  502. const original = await readForEdit(file, file)
  503. expect(original.lineEndings).toBe('CRLF')
  504. const edited = applyLiteralEdit(original.content, 'two', 'TWO', false, file)
  505. expect(restoreLineEndings(edited.content, original.lineEndings)).toBe('one\r\nTWO\r\n')
  506. })
  507. it('rejects a binary file and invalid UTF-8', async () => {
  508. await writeFile(join(dir, 'bin'), Buffer.from([0x00, 0x01]))
  509. await expect(readForEdit(join(dir, 'bin'), join(dir, 'bin'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  510. await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
  511. await expect(readForEdit(join(dir, 'bad'), join(dir, 'bad'))).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  512. })
  513. it('passes a live (non-aborted) signal through the read', async () => {
  514. const file = join(dir, 'a.txt')
  515. await writeFile(file, 'one\ntwo')
  516. const original = await readForEdit(file, file, new AbortController().signal)
  517. expect(original.content).toBe('one\ntwo')
  518. })
  519. it('translates a mid-read AbortError into FS_ABORTED', async () => {
  520. const file = join(dir, 'a.txt')
  521. await writeFile(file, 'one\ntwo')
  522. const ac = new AbortController()
  523. // Abort after the synchronous entry check, while readFile is pending.
  524. const pending = readForEdit(file, file, ac.signal)
  525. ac.abort()
  526. await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
  527. })
  528. })