atomic-write.spec.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, describe, expect, it, vi } from 'vitest'
  5. import { withFileLock, writeFileAtomic } from '../src/index.ts'
  6. const state = vi.hoisted(() => ({ failLockCreateWithEPERM: false }))
  7. vi.mock('node:fs/promises', async (importOriginal) => {
  8. const actual = await importOriginal<typeof import('node:fs/promises')>()
  9. return {
  10. ...actual,
  11. writeFile: (async (path: unknown, ...rest: never[]) => {
  12. if (state.failLockCreateWithEPERM && String(path).endsWith('.lock')) {
  13. state.failLockCreateWithEPERM = false
  14. throw Object.assign(new Error('EPERM: injected exclusive-create failure'), { code: 'EPERM' })
  15. }
  16. return (actual.writeFile as (path: unknown, ...args: never[]) => Promise<void>)(path, ...rest)
  17. }) as typeof actual.writeFile,
  18. }
  19. })
  20. afterEach(() => {
  21. state.failLockCreateWithEPERM = false
  22. })
  23. async function scratch(): Promise<string> {
  24. return mkdtemp(join(tmpdir(), 'dsh-atomic-write-'))
  25. }
  26. describe('writeFileAtomic', () => {
  27. it('creates the file and its parents with exactly the stated mode', async () => {
  28. const dir = await scratch()
  29. const target = join(dir, 'nested', 'deep', 'doc.yaml')
  30. await writeFileAtomic(target, 'a: 1\n', { mode: 0o600 })
  31. expect(await readFile(target, 'utf8')).toBe('a: 1\n')
  32. if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600)
  33. })
  34. it('replaces existing content and narrows a wider-permission file to the stated mode', async () => {
  35. const dir = await scratch()
  36. const target = join(dir, 'doc.yaml')
  37. await writeFile(target, 'old', { mode: 0o644 })
  38. await writeFileAtomic(target, 'new', { mode: 0o600 })
  39. expect(await readFile(target, 'utf8')).toBe('new')
  40. if (process.platform !== 'win32') expect((await stat(target)).mode & 0o777).toBe(0o600)
  41. })
  42. it('replaces a symlinked target itself without writing through to the referent', async () => {
  43. const dir = await scratch()
  44. const victim = join(dir, 'victim')
  45. await writeFile(victim, 'victim-content')
  46. const target = join(dir, 'doc.yaml')
  47. await symlink(victim, target)
  48. await writeFileAtomic(target, 'replaced', { mode: 0o600 })
  49. expect((await lstat(target)).isSymbolicLink()).toBe(false)
  50. expect(await readFile(target, 'utf8')).toBe('replaced')
  51. expect(await readFile(victim, 'utf8')).toBe('victim-content')
  52. })
  53. it('leaves no temp sibling and rethrows when the rename fails', async () => {
  54. const dir = await scratch()
  55. const target = join(dir, 'occupied')
  56. await mkdir(target)
  57. await expect(writeFileAtomic(target, 'content', { mode: 0o600 })).rejects.toThrow()
  58. expect((await readdir(dir)).filter(entry => entry.includes('.tmp'))).toEqual([])
  59. })
  60. })
  61. describe('withFileLock', () => {
  62. it('retries EPERM only when the lock path currently exists', async () => {
  63. const dir = await scratch()
  64. const target = join(dir, 'document')
  65. const lockPath = `${target}.lock`
  66. await writeFile(lockPath, 'holder\n')
  67. const release = setTimeout(() => { void rm(lockPath, { force: true }) }, 50)
  68. state.failLockCreateWithEPERM = true
  69. let called = false
  70. try {
  71. await withFileLock(target, async () => { called = true })
  72. } finally {
  73. clearTimeout(release)
  74. }
  75. expect(called).toBe(true)
  76. })
  77. it('preserves EPERM when no lock path exists', async () => {
  78. const dir = await scratch()
  79. const operation = vi.fn(async () => {})
  80. state.failLockCreateWithEPERM = true
  81. await expect(withFileLock(join(dir, 'document'), operation)).rejects.toMatchObject({ code: 'EPERM' })
  82. expect(operation).not.toHaveBeenCalled()
  83. })
  84. it('rejects an invalid parent hierarchy before running the operation', async () => {
  85. const dir = await scratch()
  86. const parent = join(dir, 'not-a-directory')
  87. await writeFile(parent, 'occupied')
  88. let called = false
  89. await expect(withFileLock(join(parent, 'document'), async () => {
  90. called = true
  91. })).rejects.toThrow(/ENOENT|ENOTDIR|not a directory/i)
  92. expect(called).toBe(false)
  93. })
  94. })