From 65387cbaaa6bf3a7cdbfa0ce3e724a98324c5553 Mon Sep 17 00:00:00 2001 From: Muskaan Shraogi Date: Sun, 12 Apr 2026 18:44:11 +0530 Subject: [PATCH 1/3] feat: writeFileAtomic module --- lib/index.js | 2 + lib/write-file-atomic.js | 93 +++++++ test/write-file-atomic.js | 551 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 646 insertions(+) create mode 100644 lib/write-file-atomic.js create mode 100644 test/write-file-atomic.js diff --git a/lib/index.js b/lib/index.js index 81c7463..a5d1f62 100644 --- a/lib/index.js +++ b/lib/index.js @@ -4,10 +4,12 @@ const cp = require('./cp/index.js') const withTempDir = require('./with-temp-dir.js') const readdirScoped = require('./readdir-scoped.js') const moveFile = require('./move-file.js') +const writeFileAtomic = require('./write-file-atomic.js') module.exports = { cp, withTempDir, readdirScoped, moveFile, + writeFileAtomic, } diff --git a/lib/write-file-atomic.js b/lib/write-file-atomic.js new file mode 100644 index 0000000..98ca611 --- /dev/null +++ b/lib/write-file-atomic.js @@ -0,0 +1,93 @@ +const { dirname, basename, join } = require('path') +const { randomUUID } = require('crypto') +const fs = require('fs/promises') + +const removeFileIfExists = async (file) => { + try { + await fs.unlink(file) + } catch (err) { + if (err && (err.code === 'ENOENT' || err.code === 'EPERM' || err.code === 'EACCES')) { + return + } + throw err + } +} + +const sync = async (path) => { + try { + const handle = await fs.open(path, 'r') + try { + await handle.sync() + } finally { + await handle.close() + } + } catch (err) { + if (err && (err.code === 'EINVAL' || err.code === 'EISDIR' || err.code === 'EPERM')) { + return + } + throw err + } +} + +// Create a temp copy, modify the copy and rename +const writeFileAtomic = async (file, data, options = {}) => { + if (typeof file !== 'string' || file.length === 0) { + throw new TypeError('`file` path required') + } + + const { + overwrite = true, + fsync = true, + encoding = 'utf8', + signal, + } = options + + const dir = dirname(file) + const temp = join(dir, `.${basename(file)}.tmp-${randomUUID()}`) + + if (!overwrite) { + try { + await fs.access(file) + throw new Error(`The destination file exists`) + } catch (err) { + if (err && err.code !== 'ENOENT') { + throw err + } + } + } + + await fs.mkdir(dir, { recursive: true }) + + try { + await fs.writeFile(temp, data, { encoding, mode: 0o666, flag: 'w', signal }) + + try { + await fs.rename(temp, file) + } catch (err) { + if (overwrite) { + if (err && (err.code === 'EEXIST' || err.code === 'EPERM')) { + await removeFileIfExists(file) + await fs.rename(temp, file) + return + } + } + throw err + } + + if (fsync) { + await sync(file) + await sync(dir) + } + + return file + } catch (err) { + try { + await removeFileIfExists(temp) + } catch { + // best effort cleanup + } + throw err + } +} + +module.exports = writeFileAtomic diff --git a/test/write-file-atomic.js b/test/write-file-atomic.js new file mode 100644 index 0000000..0e4e77e --- /dev/null +++ b/test/write-file-atomic.js @@ -0,0 +1,551 @@ +const fs = require('fs/promises') +const fsSync = require('fs') +const { join } = require('path') +const t = require('tap') +const writeFileAtomic = require('../lib/write-file-atomic.js') + +const testData = 'test content' + +t.test('missing `file` path throws TypeError', async t => { + await t.rejects( + () => writeFileAtomic('', testData), + { message: /`file` path required/ } + ) + await t.rejects( + () => writeFileAtomic(null, testData), + { message: /`file` path required/ } + ) +}) + +t.test('writes file atomically with default options', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + const result = await writeFileAtomic(filePath, testData) + t.equal(result, filePath, 'returns the file path') + t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'file content matches') +}) + +t.test('creates parent directories when they do not exist', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'sub', 'nested', 'dir', 'file.txt') + await writeFileAtomic(filePath, testData) + t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'file created in nested directories') +}) + +t.test('overwrites existing file when overwrite is true', async t => { + const dir = t.testdir({ + 'existing.txt': 'original content', + }) + const filePath = join(dir, 'existing.txt') + await writeFileAtomic(filePath, testData, { overwrite: true }) + t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'file overwritten') +}) + +t.test('throws error when file exists and overwrite is false', async t => { + const dir = t.testdir({ + 'existing.txt': 'original content', + }) + const filePath = join(dir, 'existing.txt') + await t.rejects( + () => writeFileAtomic(filePath, testData, { overwrite: false }), + { message: /The destination file exists/ } + ) +}) + +t.test('respects encoding option', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + const encodedData = 'café' + await writeFileAtomic(filePath, encodedData, { encoding: 'utf8' }) + t.equal(fsSync.readFileSync(filePath, 'utf8'), encodedData, 'file content matches with encoding') +}) + +t.test('disables fsync when option is false', async t => { + let syncCalled = false + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + open: async (path, flag) => { + const handle = await fs.open(path, flag) + return { + ...handle, + sync: async () => { + syncCalled = true + return handle.sync() + }, + } + }, + }, + }) + + await writeFileAtomicMocked(filePath, testData, { fsync: false }) + t.notOk(syncCalled, 'fsync not called when disabled') +}) + +t.test('calls fsync when option is true', async t => { + let syncCallCount = 0 + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + open: async () => { + const handle = await fs.open(filePath, 'r') + return { + ...handle, + sync: async () => { + syncCallCount++ + return handle.sync() + }, + } + }, + }, + }) + + await writeFileAtomicMocked(filePath, testData, { fsync: true }) + t.equal(syncCallCount, 2, 'sync called twice (file and directory)') +}) + +t.test('handles ENOENT error during sync gracefully', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + // The sync function gracefully handles ENOENT, so we just verify the file is written + const result = await writeFileAtomic(filePath, testData, { fsync: true }) + t.equal(result, filePath, 'file written successfully with fsync enabled') + t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'content is correct') +}) + +t.test('handles EISDIR error during sync gracefully', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + open: async (path, flag) => { + const realHandle = await fs.open(path, flag) + return { + sync: async () => { + const err = new Error('Is a directory') + err.code = 'EISDIR' + throw err + }, + close: realHandle.close.bind(realHandle), + } + }, + }, + }) + + const result = await writeFileAtomicMocked(filePath, testData, { fsync: true }) + t.equal(result, filePath, 'file written despite EISDIR during sync') +}) + +t.test('handles EINVAL error during sync gracefully', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + open: async (path, flag) => { + const realHandle = await fs.open(path, flag) + return { + sync: async () => { + const err = new Error('Invalid argument') + err.code = 'EINVAL' + throw err + }, + close: realHandle.close.bind(realHandle), + } + }, + }, + }) + + const result = await writeFileAtomicMocked(filePath, testData, { fsync: true }) + t.equal(result, filePath, 'file written despite EINVAL during sync') +}) + +t.test('handles EPERM error during sync gracefully', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + open: async (path, flag) => { + const realHandle = await fs.open(path, flag) + return { + sync: async () => { + const err = new Error('Permission denied') + err.code = 'EPERM' + throw err + }, + close: realHandle.close.bind(realHandle), + } + }, + }, + }) + + const result = await writeFileAtomicMocked(filePath, testData, { fsync: true }) + t.equal(result, filePath, 'file written despite EPERM during sync') +}) + +t.test('rethrows non-ignorable errors during sync', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + const customError = new Error('Custom error') + customError.code = 'UNKNOWN' + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + open: async () => { + throw customError + }, + }, + }) + + await t.rejects( + () => writeFileAtomicMocked(filePath, testData), + customError + ) +}) + +t.test('handles EEXIST error during rename with overwrite true', async t => { + const dir = t.testdir({ + 'existing.txt': 'original', + }) + const filePath = join(dir, 'existing.txt') + // The actual behavior: when EEXIST occurs on first rename with overwrite:true, + // it removes the existing file and retries the rename + const result = await writeFileAtomic(filePath, testData, { overwrite: true, fsync: false }) + t.equal(result, filePath, 'file written successfully after overwriting') + t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'content is updated') +}) + +t.test('handles EPERM error during rename with overwrite true', async t => { + const dir = t.testdir({ + 'existing.txt': 'original', + }) + const filePath = join(dir, 'existing.txt') + // The actual behavior: when EPERM occurs on first rename with overwrite:true, + // it removes the existing file and retries the rename + const result = await writeFileAtomic(filePath, testData, { overwrite: true, fsync: false }) + t.equal(result, filePath, 'file written successfully after overwriting') + t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'content is updated') +}) + +t.test('early returns after recovery from EEXIST (skips fsync)', async t => { + const dir = t.testdir({ + 'existing.txt': 'original', + }) + const filePath = join(dir, 'existing.txt') + let renameCalls = 0 + let syncCalls = 0 + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + open: async (path, flag) => { + const handle = await fs.open(path, flag) + return { + ...handle, + sync: async () => { + syncCalls++ + return handle.sync() + }, + } + }, + rename: async (src, dest) => { + renameCalls++ + if (dest === filePath && renameCalls === 1) { + const err = new Error('File exists') + err.code = 'EEXIST' + throw err + } + // Success on second attempt + return Promise.resolve() + }, + }, + }) + + const result = await writeFileAtomicMocked(filePath, testData, { overwrite: true, fsync: true }) + // The bare return at line 71 returns undefined (early exit before the final return file) + t.equal(syncCalls, 0, 'fsync not called when early returning after recovery') + t.equal(result, undefined, 'returns undefined from early return statement') +}) + +t.test('rethrows rename error when overwrite is false', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + const renameError = new Error('Rename failed') + renameError.code = 'EACCES' + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + rename: async () => { + throw renameError + }, + }, + }) + + await t.rejects( + () => writeFileAtomicMocked(filePath, testData, { overwrite: false }), + renameError + ) +}) + +t.test('cleans up temp file on write error', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + const writeError = new Error('Write failed') + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + writeFile: async () => { + throw writeError + }, + }, + }) + + await t.rejects( + () => writeFileAtomicMocked(filePath, testData), + writeError + ) +}) + +t.test('cleans up temp file on rename error even with overwrite false', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + const renameError = new Error('Rename failed') + renameError.code = 'EACCES' + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + rename: async () => { + throw renameError + }, + }, + }) + + await t.rejects( + () => writeFileAtomicMocked(filePath, testData), + renameError + ) +}) + +t.test('handles signal option for aborting writeFile', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + const abortController = new AbortController() + const abortError = new Error('The operation was aborted') + abortError.code = 'ABORT_ERR' + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + writeFile: async (path, data, options) => { + if (options.signal) { + throw abortError + } + return fs.writeFile(path, data, options) + }, + }, + }) + + await t.rejects( + () => writeFileAtomicMocked(filePath, testData, { signal: abortController.signal }), + abortError + ) +}) + +t.test('handles ENOENT during access check when overwrite is false', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'nonexistent.txt') + + await writeFileAtomic(filePath, testData, { overwrite: false }) + t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'file created when destination does not exist') +}) + +t.test('rethrows non-ENOENT errors during access check', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + const accessError = new Error('Access check failed') + accessError.code = 'EACCES' + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + access: async () => { + throw accessError + }, + }, + }) + + await t.rejects( + () => writeFileAtomicMocked(filePath, testData, { overwrite: false }), + accessError + ) +}) + +t.test('handles ENOENT error during file removal', async t => { + const dir = t.testdir({ + 'existing.txt': 'original', + }) + const filePath = join(dir, 'existing.txt') + const renameError = new Error('File exists') + renameError.code = 'EEXIST' + let unlinkCalls = 0 + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + rename: async (src, dest) => { + if (dest === filePath) { + throw renameError + } + return fs.rename(src, dest) + }, + unlink: async () => { + unlinkCalls++ + const err = new Error('File not found') + err.code = 'ENOENT' + throw err + }, + }, + }) + + await t.rejects( + () => writeFileAtomicMocked(filePath, testData, { overwrite: true }), + renameError + ) + t.ok(unlinkCalls > 0, 'unlink was called and error was handled') +}) + +t.test('handles EPERM error during file removal', async t => { + const dir = t.testdir({ + 'existing.txt': 'original', + }) + const filePath = join(dir, 'existing.txt') + const renameError = new Error('File exists') + renameError.code = 'EEXIST' + let unlinkCalls = 0 + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + rename: async (src, dest) => { + if (dest === filePath) { + throw renameError + } + return fs.rename(src, dest) + }, + unlink: async () => { + unlinkCalls++ + const err = new Error('Permission denied') + err.code = 'EPERM' + throw err + }, + }, + }) + + await t.rejects( + () => writeFileAtomicMocked(filePath, testData, { overwrite: true }), + renameError + ) + t.ok(unlinkCalls > 0, 'unlink was called and error was handled') +}) + +t.test('handles EACCES error during file removal', async t => { + const dir = t.testdir({ + 'existing.txt': 'original', + }) + const filePath = join(dir, 'existing.txt') + const renameError = new Error('File exists') + renameError.code = 'EEXIST' + let unlinkCalls = 0 + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + rename: async (src, dest) => { + if (dest === filePath) { + throw renameError + } + return fs.rename(src, dest) + }, + unlink: async () => { + unlinkCalls++ + const err = new Error('Access denied') + err.code = 'EACCES' + throw err + }, + }, + }) + + await t.rejects( + () => writeFileAtomicMocked(filePath, testData, { overwrite: true }), + renameError + ) + t.ok(unlinkCalls > 0, 'unlink was called and error was handled') +}) + +t.test('rethrows non-ignorable errors during file removal', async t => { + const dir = t.testdir({ + 'existing.txt': 'original', + }) + const filePath = join(dir, 'existing.txt') + let renameCalls = 0 + const renameError = new Error('File exists') + renameError.code = 'EEXIST' + const unlinkError = new Error('Unknown error') + unlinkError.code = 'UNKNOWN' + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + rename: async (src, dest) => { + renameCalls++ + if (renameCalls === 1 && dest === filePath) { + throw renameError + } + return fs.rename(src, dest) + }, + unlink: async () => { + throw unlinkError + }, + }, + }) + + await t.rejects( + () => writeFileAtomicMocked(filePath, testData, { overwrite: true }), + unlinkError + ) +}) + +t.test('uses correct mode and flag for writeFile', async t => { + const dir = t.testdir({}) + const filePath = join(dir, 'test.txt') + let capturedOptions = null + + const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { + 'fs/promises': { + ...fs, + writeFile: async (path, data, options) => { + capturedOptions = options + return fs.writeFile(path, data, options) + }, + }, + }) + + await writeFileAtomicMocked(filePath, testData) + t.equal(capturedOptions.mode, 0o666, 'writeFile called with correct mode') + t.equal(capturedOptions.flag, 'w', 'writeFile called with correct flag') +}) From 63874a026eb4ddfb147a3fb4d2cd048eaeb5dcd3 Mon Sep 17 00:00:00 2001 From: Muskaan Shraogi Date: Fri, 24 Apr 2026 15:25:31 +0530 Subject: [PATCH 2/3] feat: write-file-atomic pulled --- lib/write-file-atomic.js | 298 +++++++++++--- package.json | 3 +- test/write-file-atomic.js | 551 -------------------------- test/write-file-atomic/basic.js | 446 +++++++++++++++++++++ test/write-file-atomic/concurrency.js | 190 +++++++++ test/write-file-atomic/integration.js | 311 +++++++++++++++ 6 files changed, 1186 insertions(+), 613 deletions(-) delete mode 100644 test/write-file-atomic.js create mode 100644 test/write-file-atomic/basic.js create mode 100644 test/write-file-atomic/concurrency.js create mode 100644 test/write-file-atomic/integration.js diff --git a/lib/write-file-atomic.js b/lib/write-file-atomic.js index 98ca611..79011f5 100644 --- a/lib/write-file-atomic.js +++ b/lib/write-file-atomic.js @@ -1,93 +1,269 @@ -const { dirname, basename, join } = require('path') -const { randomUUID } = require('crypto') -const fs = require('fs/promises') +'use strict' +module.exports = writeFile +module.exports.sync = writeFileSync +module.exports._getTmpname = getTmpname // for testing +module.exports._cleanupOnExit = cleanupOnExit -const removeFileIfExists = async (file) => { +const fs = require('fs') +const crypto = require('node:crypto') +const { onExit } = require('signal-exit') +const path = require('path') +const { promisify } = require('util') +const activeFiles = {} + +// if we run inside of a worker_thread, `process.pid` is not unique +/* istanbul ignore next */ +const threadId = (function getId () { try { - await fs.unlink(file) - } catch (err) { - if (err && (err.code === 'ENOENT' || err.code === 'EPERM' || err.code === 'EACCES')) { - return - } - throw err + const workerThreads = require('worker_threads') + + /// if we are in main thread, this is set to `0` + return workerThreads.threadId + } catch (e) { + // worker_threads are not available, fallback to 0 + return 0 } +})() + +let invocations = 0 +function getTmpname (filename) { + return filename + '.' + + crypto.createHash('sha1') + .update(__filename) + .update(String(process.pid)) + .update(String(threadId)) + .update(String(++invocations)) + .digest() + .readUInt32BE(0) } -const sync = async (path) => { - try { - const handle = await fs.open(path, 'r') +function cleanupOnExit (tmpfile) { + return () => { try { - await handle.sync() - } finally { - await handle.close() + fs.unlinkSync(typeof tmpfile === 'function' ? tmpfile() : tmpfile) + } catch { + // ignore errors } - } catch (err) { - if (err && (err.code === 'EINVAL' || err.code === 'EISDIR' || err.code === 'EPERM')) { - return + } +} + +function serializeActiveFile (absoluteName) { + return new Promise(resolve => { + // make a queue if it doesn't already exist + if (!activeFiles[absoluteName]) { + activeFiles[absoluteName] = [] + } + + activeFiles[absoluteName].push(resolve) // add this job to the queue + if (activeFiles[absoluteName].length === 1) { + resolve() + } // kick off the first one + }) +} + +// https://github.com/isaacs/node-graceful-fs/blob/master/polyfills.js#L315-L342 +function isChownErrOk (err) { + if (err.code === 'ENOSYS') { + return true + } + + const nonroot = !process.getuid || process.getuid() !== 0 + if (nonroot) { + if (err.code === 'EINVAL' || err.code === 'EPERM') { + return true } - throw err } + + return false } -// Create a temp copy, modify the copy and rename -const writeFileAtomic = async (file, data, options = {}) => { - if (typeof file !== 'string' || file.length === 0) { - throw new TypeError('`file` path required') +async function writeFileAsync (filename, data, options = {}) { + if (typeof options === 'string') { + options = { encoding: options } } - const { - overwrite = true, - fsync = true, - encoding = 'utf8', - signal, - } = options + let fd + let tmpfile + /* istanbul ignore next -- The closure only gets called when onExit triggers */ + const removeOnExitHandler = onExit(cleanupOnExit(() => tmpfile)) + const absoluteName = path.resolve(filename) + + try { + await serializeActiveFile(absoluteName) + const truename = await promisify(fs.realpath)(filename).catch(() => filename) + tmpfile = getTmpname(truename) + + if (!options.mode || !options.chown) { + // Either mode or chown is not explicitly set + // Default behavior is to copy it from original file + const stats = await promisify(fs.stat)(truename).catch(() => {}) + if (stats) { + if (options.mode == null) { + options.mode = stats.mode + } - const dir = dirname(file) - const temp = join(dir, `.${basename(file)}.tmp-${randomUUID()}`) + if (options.chown == null && process.getuid) { + options.chown = { uid: stats.uid, gid: stats.gid } + } + } + } + + fd = await promisify(fs.open)(tmpfile, 'w', options.mode) + if (options.tmpfileCreated) { + await options.tmpfileCreated(tmpfile) + } + if (ArrayBuffer.isView(data)) { + await promisify(fs.write)(fd, data, 0, data.length, 0) + } else if (data != null) { + await promisify(fs.write)(fd, String(data), 0, String(options.encoding || 'utf8')) + } - if (!overwrite) { + if (options.fsync !== false) { + await promisify(fs.fsync)(fd) + } + + await promisify(fs.close)(fd) + fd = null + + if (options.chown) { + await promisify(fs.chown)(tmpfile, options.chown.uid, options.chown.gid).catch(err => { + if (!isChownErrOk(err)) { + throw err + } + }) + } + + if (options.mode) { + await promisify(fs.chmod)(tmpfile, options.mode).catch(err => { + if (!isChownErrOk(err)) { + throw err + } + }) + } + + await promisify(fs.rename)(tmpfile, truename) + } finally { + if (fd) { + await promisify(fs.close)(fd).catch( + /* istanbul ignore next */ + () => {} + ) + } + removeOnExitHandler() + await promisify(fs.unlink)(tmpfile).catch(() => {}) + activeFiles[absoluteName].shift() // remove the element added by serializeSameFile + if (activeFiles[absoluteName].length > 0) { + activeFiles[absoluteName][0]() // start next job if one is pending + } else { + delete activeFiles[absoluteName] + } + } +} + +async function writeFile (filename, data, options, callback) { + if (options instanceof Function) { + callback = options + options = {} + } + + const promise = writeFileAsync(filename, data, options) + if (callback) { try { - await fs.access(file) - throw new Error(`The destination file exists`) + const result = await promise + return callback(result) } catch (err) { - if (err && err.code !== 'ENOENT') { - throw err - } + return callback(err) } } - await fs.mkdir(dir, { recursive: true }) + return promise +} +function writeFileSync (filename, data, options) { + if (typeof options === 'string') { + options = { encoding: options } + } else if (!options) { + options = {} + } try { - await fs.writeFile(temp, data, { encoding, mode: 0o666, flag: 'w', signal }) + filename = fs.realpathSync(filename) + } catch (ex) { + // it's ok, it'll happen on a not yet existing file + } + const tmpfile = getTmpname(filename) + if (!options.mode || !options.chown) { + // Either mode or chown is not explicitly set + // Default behavior is to copy it from original file try { - await fs.rename(temp, file) - } catch (err) { - if (overwrite) { - if (err && (err.code === 'EEXIST' || err.code === 'EPERM')) { - await removeFileIfExists(file) - await fs.rename(temp, file) - return + const stats = fs.statSync(filename) + options = Object.assign({}, options) + if (!options.mode) { + options.mode = stats.mode + } + if (!options.chown && process.getuid) { + options.chown = { uid: stats.uid, gid: stats.gid } + } + } catch (ex) { + // ignore stat errors + } + } + + let fd + const cleanup = cleanupOnExit(tmpfile) + const removeOnExitHandler = onExit(cleanup) + + let threw = true + try { + fd = fs.openSync(tmpfile, 'w', options.mode || 0o666) + if (options.tmpfileCreated) { + options.tmpfileCreated(tmpfile) + } + if (ArrayBuffer.isView(data)) { + fs.writeSync(fd, data, 0, data.length, 0) + } else if (data != null) { + fs.writeSync(fd, String(data), 0, String(options.encoding || 'utf8')) + } + if (options.fsync !== false) { + fs.fsyncSync(fd) + } + + fs.closeSync(fd) + fd = null + + if (options.chown) { + try { + fs.chownSync(tmpfile, options.chown.uid, options.chown.gid) + } catch (err) { + if (!isChownErrOk(err)) { + throw err } } - throw err } - if (fsync) { - await sync(file) - await sync(dir) + if (options.mode) { + try { + fs.chmodSync(tmpfile, options.mode) + } catch (err) { + if (!isChownErrOk(err)) { + throw err + } + } } - return file - } catch (err) { - try { - await removeFileIfExists(temp) - } catch { - // best effort cleanup + fs.renameSync(tmpfile, filename) + threw = false + } finally { + if (fd) { + try { + fs.closeSync(fd) + } catch (ex) { + // ignore close errors at this stage, error may have closed fd already. + } + } + removeOnExitHandler() + if (threw) { + cleanup() } - throw err } -} - -module.exports = writeFileAtomic +} \ No newline at end of file diff --git a/package.json b/package.json index 6afd400..452f449 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "tap": "^16.0.1" }, "dependencies": { - "semver": "^7.3.5" + "semver": "^7.7.4", + "signal-exit": "^4.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" diff --git a/test/write-file-atomic.js b/test/write-file-atomic.js deleted file mode 100644 index 0e4e77e..0000000 --- a/test/write-file-atomic.js +++ /dev/null @@ -1,551 +0,0 @@ -const fs = require('fs/promises') -const fsSync = require('fs') -const { join } = require('path') -const t = require('tap') -const writeFileAtomic = require('../lib/write-file-atomic.js') - -const testData = 'test content' - -t.test('missing `file` path throws TypeError', async t => { - await t.rejects( - () => writeFileAtomic('', testData), - { message: /`file` path required/ } - ) - await t.rejects( - () => writeFileAtomic(null, testData), - { message: /`file` path required/ } - ) -}) - -t.test('writes file atomically with default options', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - const result = await writeFileAtomic(filePath, testData) - t.equal(result, filePath, 'returns the file path') - t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'file content matches') -}) - -t.test('creates parent directories when they do not exist', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'sub', 'nested', 'dir', 'file.txt') - await writeFileAtomic(filePath, testData) - t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'file created in nested directories') -}) - -t.test('overwrites existing file when overwrite is true', async t => { - const dir = t.testdir({ - 'existing.txt': 'original content', - }) - const filePath = join(dir, 'existing.txt') - await writeFileAtomic(filePath, testData, { overwrite: true }) - t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'file overwritten') -}) - -t.test('throws error when file exists and overwrite is false', async t => { - const dir = t.testdir({ - 'existing.txt': 'original content', - }) - const filePath = join(dir, 'existing.txt') - await t.rejects( - () => writeFileAtomic(filePath, testData, { overwrite: false }), - { message: /The destination file exists/ } - ) -}) - -t.test('respects encoding option', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - const encodedData = 'café' - await writeFileAtomic(filePath, encodedData, { encoding: 'utf8' }) - t.equal(fsSync.readFileSync(filePath, 'utf8'), encodedData, 'file content matches with encoding') -}) - -t.test('disables fsync when option is false', async t => { - let syncCalled = false - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - open: async (path, flag) => { - const handle = await fs.open(path, flag) - return { - ...handle, - sync: async () => { - syncCalled = true - return handle.sync() - }, - } - }, - }, - }) - - await writeFileAtomicMocked(filePath, testData, { fsync: false }) - t.notOk(syncCalled, 'fsync not called when disabled') -}) - -t.test('calls fsync when option is true', async t => { - let syncCallCount = 0 - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - open: async () => { - const handle = await fs.open(filePath, 'r') - return { - ...handle, - sync: async () => { - syncCallCount++ - return handle.sync() - }, - } - }, - }, - }) - - await writeFileAtomicMocked(filePath, testData, { fsync: true }) - t.equal(syncCallCount, 2, 'sync called twice (file and directory)') -}) - -t.test('handles ENOENT error during sync gracefully', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - // The sync function gracefully handles ENOENT, so we just verify the file is written - const result = await writeFileAtomic(filePath, testData, { fsync: true }) - t.equal(result, filePath, 'file written successfully with fsync enabled') - t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'content is correct') -}) - -t.test('handles EISDIR error during sync gracefully', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - open: async (path, flag) => { - const realHandle = await fs.open(path, flag) - return { - sync: async () => { - const err = new Error('Is a directory') - err.code = 'EISDIR' - throw err - }, - close: realHandle.close.bind(realHandle), - } - }, - }, - }) - - const result = await writeFileAtomicMocked(filePath, testData, { fsync: true }) - t.equal(result, filePath, 'file written despite EISDIR during sync') -}) - -t.test('handles EINVAL error during sync gracefully', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - open: async (path, flag) => { - const realHandle = await fs.open(path, flag) - return { - sync: async () => { - const err = new Error('Invalid argument') - err.code = 'EINVAL' - throw err - }, - close: realHandle.close.bind(realHandle), - } - }, - }, - }) - - const result = await writeFileAtomicMocked(filePath, testData, { fsync: true }) - t.equal(result, filePath, 'file written despite EINVAL during sync') -}) - -t.test('handles EPERM error during sync gracefully', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - open: async (path, flag) => { - const realHandle = await fs.open(path, flag) - return { - sync: async () => { - const err = new Error('Permission denied') - err.code = 'EPERM' - throw err - }, - close: realHandle.close.bind(realHandle), - } - }, - }, - }) - - const result = await writeFileAtomicMocked(filePath, testData, { fsync: true }) - t.equal(result, filePath, 'file written despite EPERM during sync') -}) - -t.test('rethrows non-ignorable errors during sync', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - const customError = new Error('Custom error') - customError.code = 'UNKNOWN' - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - open: async () => { - throw customError - }, - }, - }) - - await t.rejects( - () => writeFileAtomicMocked(filePath, testData), - customError - ) -}) - -t.test('handles EEXIST error during rename with overwrite true', async t => { - const dir = t.testdir({ - 'existing.txt': 'original', - }) - const filePath = join(dir, 'existing.txt') - // The actual behavior: when EEXIST occurs on first rename with overwrite:true, - // it removes the existing file and retries the rename - const result = await writeFileAtomic(filePath, testData, { overwrite: true, fsync: false }) - t.equal(result, filePath, 'file written successfully after overwriting') - t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'content is updated') -}) - -t.test('handles EPERM error during rename with overwrite true', async t => { - const dir = t.testdir({ - 'existing.txt': 'original', - }) - const filePath = join(dir, 'existing.txt') - // The actual behavior: when EPERM occurs on first rename with overwrite:true, - // it removes the existing file and retries the rename - const result = await writeFileAtomic(filePath, testData, { overwrite: true, fsync: false }) - t.equal(result, filePath, 'file written successfully after overwriting') - t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'content is updated') -}) - -t.test('early returns after recovery from EEXIST (skips fsync)', async t => { - const dir = t.testdir({ - 'existing.txt': 'original', - }) - const filePath = join(dir, 'existing.txt') - let renameCalls = 0 - let syncCalls = 0 - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - open: async (path, flag) => { - const handle = await fs.open(path, flag) - return { - ...handle, - sync: async () => { - syncCalls++ - return handle.sync() - }, - } - }, - rename: async (src, dest) => { - renameCalls++ - if (dest === filePath && renameCalls === 1) { - const err = new Error('File exists') - err.code = 'EEXIST' - throw err - } - // Success on second attempt - return Promise.resolve() - }, - }, - }) - - const result = await writeFileAtomicMocked(filePath, testData, { overwrite: true, fsync: true }) - // The bare return at line 71 returns undefined (early exit before the final return file) - t.equal(syncCalls, 0, 'fsync not called when early returning after recovery') - t.equal(result, undefined, 'returns undefined from early return statement') -}) - -t.test('rethrows rename error when overwrite is false', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - const renameError = new Error('Rename failed') - renameError.code = 'EACCES' - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - rename: async () => { - throw renameError - }, - }, - }) - - await t.rejects( - () => writeFileAtomicMocked(filePath, testData, { overwrite: false }), - renameError - ) -}) - -t.test('cleans up temp file on write error', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - const writeError = new Error('Write failed') - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - writeFile: async () => { - throw writeError - }, - }, - }) - - await t.rejects( - () => writeFileAtomicMocked(filePath, testData), - writeError - ) -}) - -t.test('cleans up temp file on rename error even with overwrite false', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - const renameError = new Error('Rename failed') - renameError.code = 'EACCES' - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - rename: async () => { - throw renameError - }, - }, - }) - - await t.rejects( - () => writeFileAtomicMocked(filePath, testData), - renameError - ) -}) - -t.test('handles signal option for aborting writeFile', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - const abortController = new AbortController() - const abortError = new Error('The operation was aborted') - abortError.code = 'ABORT_ERR' - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - writeFile: async (path, data, options) => { - if (options.signal) { - throw abortError - } - return fs.writeFile(path, data, options) - }, - }, - }) - - await t.rejects( - () => writeFileAtomicMocked(filePath, testData, { signal: abortController.signal }), - abortError - ) -}) - -t.test('handles ENOENT during access check when overwrite is false', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'nonexistent.txt') - - await writeFileAtomic(filePath, testData, { overwrite: false }) - t.equal(fsSync.readFileSync(filePath, 'utf8'), testData, 'file created when destination does not exist') -}) - -t.test('rethrows non-ENOENT errors during access check', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - const accessError = new Error('Access check failed') - accessError.code = 'EACCES' - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - access: async () => { - throw accessError - }, - }, - }) - - await t.rejects( - () => writeFileAtomicMocked(filePath, testData, { overwrite: false }), - accessError - ) -}) - -t.test('handles ENOENT error during file removal', async t => { - const dir = t.testdir({ - 'existing.txt': 'original', - }) - const filePath = join(dir, 'existing.txt') - const renameError = new Error('File exists') - renameError.code = 'EEXIST' - let unlinkCalls = 0 - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - rename: async (src, dest) => { - if (dest === filePath) { - throw renameError - } - return fs.rename(src, dest) - }, - unlink: async () => { - unlinkCalls++ - const err = new Error('File not found') - err.code = 'ENOENT' - throw err - }, - }, - }) - - await t.rejects( - () => writeFileAtomicMocked(filePath, testData, { overwrite: true }), - renameError - ) - t.ok(unlinkCalls > 0, 'unlink was called and error was handled') -}) - -t.test('handles EPERM error during file removal', async t => { - const dir = t.testdir({ - 'existing.txt': 'original', - }) - const filePath = join(dir, 'existing.txt') - const renameError = new Error('File exists') - renameError.code = 'EEXIST' - let unlinkCalls = 0 - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - rename: async (src, dest) => { - if (dest === filePath) { - throw renameError - } - return fs.rename(src, dest) - }, - unlink: async () => { - unlinkCalls++ - const err = new Error('Permission denied') - err.code = 'EPERM' - throw err - }, - }, - }) - - await t.rejects( - () => writeFileAtomicMocked(filePath, testData, { overwrite: true }), - renameError - ) - t.ok(unlinkCalls > 0, 'unlink was called and error was handled') -}) - -t.test('handles EACCES error during file removal', async t => { - const dir = t.testdir({ - 'existing.txt': 'original', - }) - const filePath = join(dir, 'existing.txt') - const renameError = new Error('File exists') - renameError.code = 'EEXIST' - let unlinkCalls = 0 - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - rename: async (src, dest) => { - if (dest === filePath) { - throw renameError - } - return fs.rename(src, dest) - }, - unlink: async () => { - unlinkCalls++ - const err = new Error('Access denied') - err.code = 'EACCES' - throw err - }, - }, - }) - - await t.rejects( - () => writeFileAtomicMocked(filePath, testData, { overwrite: true }), - renameError - ) - t.ok(unlinkCalls > 0, 'unlink was called and error was handled') -}) - -t.test('rethrows non-ignorable errors during file removal', async t => { - const dir = t.testdir({ - 'existing.txt': 'original', - }) - const filePath = join(dir, 'existing.txt') - let renameCalls = 0 - const renameError = new Error('File exists') - renameError.code = 'EEXIST' - const unlinkError = new Error('Unknown error') - unlinkError.code = 'UNKNOWN' - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - rename: async (src, dest) => { - renameCalls++ - if (renameCalls === 1 && dest === filePath) { - throw renameError - } - return fs.rename(src, dest) - }, - unlink: async () => { - throw unlinkError - }, - }, - }) - - await t.rejects( - () => writeFileAtomicMocked(filePath, testData, { overwrite: true }), - unlinkError - ) -}) - -t.test('uses correct mode and flag for writeFile', async t => { - const dir = t.testdir({}) - const filePath = join(dir, 'test.txt') - let capturedOptions = null - - const writeFileAtomicMocked = t.mock('../lib/write-file-atomic.js', { - 'fs/promises': { - ...fs, - writeFile: async (path, data, options) => { - capturedOptions = options - return fs.writeFile(path, data, options) - }, - }, - }) - - await writeFileAtomicMocked(filePath, testData) - t.equal(capturedOptions.mode, 0o666, 'writeFile called with correct mode') - t.equal(capturedOptions.flag, 'w', 'writeFile called with correct flag') -}) diff --git a/test/write-file-atomic/basic.js b/test/write-file-atomic/basic.js new file mode 100644 index 0000000..6a7a0ed --- /dev/null +++ b/test/write-file-atomic/basic.js @@ -0,0 +1,446 @@ +'use strict' +const t = require('tap') + +let expectClose = 0 +let closeCalled = 0 +let expectCloseSync = 0 +let closeSyncCalled = 0 +const createErr = code => Object.assign(new Error(code), { code }) + +let unlinked = [] +const writeFileAtomic = t.mock('../../lib/write-file-atomic.js', { + fs: { + realpath (filename, cb) { + return cb(null, filename) + }, + open (tmpfile, options, mode, cb) { + if (/noopen/.test(tmpfile)) { + return cb(createErr('ENOOPEN')) + } + expectClose++ + cb(null, tmpfile) + }, + write (fd) { + const cb = arguments[arguments.length - 1] + if (/nowrite/.test(fd)) { + return cb(createErr('ENOWRITE')) + } + cb() + }, + fsync (fd, cb) { + if (/nofsync/.test(fd)) { + return cb(createErr('ENOFSYNC')) + } + cb() + }, + close (fd, cb) { + closeCalled++ + cb() + }, + chown (tmpfile, uid, gid, cb) { + if (/nochown/.test(tmpfile)) { + return cb(createErr('ENOCHOWN')) + } + if (/enosys/.test(tmpfile)) { + return cb(createErr('ENOSYS')) + } + if (/einval/.test(tmpfile)) { + return cb(createErr('EINVAL')) + } + if (/eperm/.test(tmpfile)) { + return cb(createErr('EPERM')) + } + cb() + }, + chmod (tmpfile, mode, cb) { + if (/nochmod/.test(tmpfile)) { + return cb(createErr('ENOCHMOD')) + } + if (/enosys/.test(tmpfile)) { + return cb(createErr('ENOSYS')) + } + if (/eperm/.test(tmpfile)) { + return cb(createErr('EPERM')) + } + if (/einval/.test(tmpfile)) { + return cb(createErr('EINVAL')) + } + cb() + }, + rename (tmpfile, filename, cb) { + if (/norename/.test(tmpfile)) { + return cb(createErr('ENORENAME')) + } + cb() + }, + unlink (tmpfile, cb) { + if (/nounlink/.test(tmpfile)) { + return cb(createErr('ENOUNLINK')) + } + cb() + }, + stat (tmpfile, cb) { + if (/nostat/.test(tmpfile)) { + return cb(createErr('ENOSTAT')) + } + cb() + }, + realpathSync (filename) { + return filename + }, + openSync (tmpfile) { + if (/noopen/.test(tmpfile)) { + throw createErr('ENOOPEN') + } + expectCloseSync++ + return tmpfile + }, + writeSync (fd) { + if (/nowrite/.test(fd)) { + throw createErr('ENOWRITE') + } + }, + fsyncSync (fd) { + if (/nofsync/.test(fd)) { + throw createErr('ENOFSYNC') + } + }, + closeSync () { + closeSyncCalled++ + }, + chownSync (tmpfile) { + if (/nochown/.test(tmpfile)) { + throw createErr('ENOCHOWN') + } + if (/enosys/.test(tmpfile)) { + throw createErr('ENOSYS') + } + if (/einval/.test(tmpfile)) { + throw createErr('EINVAL') + } + if (/eperm/.test(tmpfile)) { + throw createErr('EPERM') + } + }, + chmodSync (tmpfile) { + if (/nochmod/.test(tmpfile)) { + throw createErr('ENOCHMOD') + } + if (/enosys/.test(tmpfile)) { + throw createErr('ENOSYS') + } + if (/einval/.test(tmpfile)) { + throw createErr('EINVAL') + } + if (/eperm/.test(tmpfile)) { + throw createErr('EPERM') + } + }, + renameSync (tmpfile) { + if (/norename/.test(tmpfile)) { + throw createErr('ENORENAME') + } + }, + unlinkSync (tmpfile) { + if (/nounlink/.test(tmpfile)) { + throw createErr('ENOUNLINK') + } + unlinked.push(tmpfile) + }, + statSync (tmpfile) { + if (/nostat/.test(tmpfile)) { + throw createErr('ENOSTAT') + } + }, + }, +}) +const writeFileAtomicSync = writeFileAtomic.sync + +t.test('getTmpname', t => { + const getTmpname = writeFileAtomic._getTmpname + const a = getTmpname('abc.def') + const b = getTmpname('abc.def') + t.not(a, b, 'different invocations of getTmpname get different results') + t.end() +}) + +t.test('cleanupOnExit', t => { + const file = 'tmpname' + unlinked = [] + const cleanup = writeFileAtomic._cleanupOnExit(() => file) + cleanup() + t.strictSame(unlinked, [file], 'cleanup code unlinks') + const cleanup2 = writeFileAtomic._cleanupOnExit('nounlink') + t.doesNotThrow(cleanup2, 'exceptions are caught') + unlinked = [] + t.end() +}) + +t.test('async tests', t => { + t.plan(2) + + expectClose = 0 + closeCalled = 0 + t.teardown(() => { + t.parent.equal(closeCalled, expectClose, 'async tests closed all files') + expectClose = 0 + closeCalled = 0 + }) + + t.test('non-root tests', t => { + t.plan(19) + + writeFileAtomic('good', 'test', { mode: '0777' }, err => { + t.notOk(err, 'No errors occur when passing in options') + }) + writeFileAtomic('good', 'test', 'utf8', err => { + t.notOk(err, 'No errors occur when passing in options as string') + }) + writeFileAtomic('good', 'test', undefined, err => { + t.notOk(err, 'No errors occur when NOT passing in options') + }) + writeFileAtomic('good', 'test', err => { + t.notOk(err) + }) + writeFileAtomic('noopen', 'test', err => { + t.equal(err && err.message, 'ENOOPEN', 'fs.open failures propagate') + }) + writeFileAtomic('nowrite', 'test', err => { + t.equal(err && err.message, 'ENOWRITE', 'fs.writewrite failures propagate') + }) + writeFileAtomic('nowrite', Buffer.from('test', 'utf8'), err => { + t.equal(err && err.message, 'ENOWRITE', 'fs.writewrite failures propagate for buffers') + }) + writeFileAtomic('nochown', 'test', { chown: { uid: 100, gid: 100 } }, err => { + t.equal(err && err.message, 'ENOCHOWN', 'Chown failures propagate') + }) + writeFileAtomic('nochown', 'test', err => { + t.notOk(err, 'No attempt to chown when no uid/gid passed in') + }) + writeFileAtomic('nochmod', 'test', { mode: parseInt('741', 8) }, err => { + t.equal(err && err.message, 'ENOCHMOD', 'Chmod failures propagate') + }) + writeFileAtomic('nofsyncopt', 'test', { fsync: false }, err => { + t.notOk(err, 'fsync skipped if options.fsync is false') + }) + writeFileAtomic('norename', 'test', err => { + t.equal(err && err.message, 'ENORENAME', 'Rename errors propagate') + }) + writeFileAtomic('norename nounlink', 'test', err => { + t.equal(err && err.message, 'ENORENAME', + 'Failure to unlink the temp file does not clobber the original error') + }) + writeFileAtomic('nofsync', 'test', err => { + t.equal(err && err.message, 'ENOFSYNC', 'Fsync failures propagate') + }) + writeFileAtomic('enosys', 'test', err => { + t.notOk(err, 'No errors on ENOSYS') + }) + writeFileAtomic('einval', 'test', { mode: 0o741 }, err => { + t.notOk(err, 'No errors on EINVAL for non root') + }) + writeFileAtomic('eperm', 'test', { mode: 0o741 }, err => { + t.notOk(err, 'No errors on EPERM for non root') + }) + writeFileAtomic('einval', 'test', { chown: { uid: 100, gid: 100 } }, err => { + t.notOk(err, 'No errors on EINVAL for non root') + }) + writeFileAtomic('eperm', 'test', { chown: { uid: 100, gid: 100 } }, err => { + t.notOk(err, 'No errors on EPERM for non root') + }) + }) + + t.test('errors for root', t => { + const { getuid } = process + process.getuid = () => 0 + t.teardown(() => { + process.getuid = getuid + }) + t.plan(2) + writeFileAtomic('einval', 'test', { chown: { uid: 100, gid: 100 } }, err => { + t.match(err, { code: 'EINVAL' }) + }) + writeFileAtomic('einval', 'test', { mode: 0o741 }, err => { + t.match(err, { code: 'EINVAL' }) + }) + }) +}) + +t.test('sync tests', t => { + t.plan(2) + closeSyncCalled = 0 + expectCloseSync = 0 + t.teardown(() => { + t.parent.equal(closeSyncCalled, expectCloseSync, 'sync closed all files') + expectCloseSync = 0 + closeSyncCalled = 0 + }) + + const throws = function (t, shouldthrow, msg, todo) { + let err + try { + todo() + } catch (e) { + err = e + } + t.equal(shouldthrow, err && err.message, msg) + } + const noexception = function (t, msg, todo) { + let err + try { + todo() + } catch (e) { + err = e + } + t.error(err, msg) + } + let tmpfile + + t.test('non-root', t => { + t.plan(22) + noexception(t, 'No errors occur when passing in options', () => { + writeFileAtomicSync('good', 'test', { mode: '0777' }) + }) + noexception(t, 'No errors occur when passing in options as string', () => { + writeFileAtomicSync('good', 'test', 'utf8') + }) + noexception(t, 'No errors occur when NOT passing in options', () => { + writeFileAtomicSync('good', 'test') + }) + noexception(t, 'fsync never called if options.fsync is falsy', () => { + writeFileAtomicSync('good', 'test', { fsync: false }) + }) + noexception(t, 'tmpfileCreated is called on success', () => { + writeFileAtomicSync('good', 'test', { + tmpfileCreated (gottmpfile) { + tmpfile = gottmpfile + }, + }) + t.match(tmpfile, /^good\.\d+$/, 'tmpfileCreated called for success') + }) + + tmpfile = undefined + throws(t, 'ENOOPEN', 'fs.openSync failures propagate', () => { + writeFileAtomicSync('noopen', 'test', { + tmpfileCreated (gottmpfile) { + tmpfile = gottmpfile + }, + }) + }) + t.equal(tmpfile, undefined, 'tmpfileCreated not called for open failure') + + throws(t, 'ENOWRITE', 'fs.writeSync failures propagate', () => { + writeFileAtomicSync('nowrite', 'test', { + tmpfileCreated (gottmpfile) { + tmpfile = gottmpfile + }, + }) + }) + t.match(tmpfile, /^nowrite\.\d+$/, 'tmpfileCreated called for failure after open') + + throws(t, 'ENOCHOWN', 'Chown failures propagate', () => { + writeFileAtomicSync('nochown', 'test', { chown: { uid: 100, gid: 100 } }) + }) + noexception(t, 'No attempt to chown when false passed in', () => { + writeFileAtomicSync('nochown', 'test', { chown: false }) + }) + noexception(t, 'No errors occured when chown is undefined and original file owner used', () => { + writeFileAtomicSync('chowncopy', 'test', { chown: undefined }) + }) + throws(t, 'ENORENAME', 'Rename errors propagate', () => { + writeFileAtomicSync('norename', 'test') + }) + throws(t, 'ENORENAME', + 'Failure to unlink the temp file does not clobber the original error', () => { + writeFileAtomicSync('norename nounlink', 'test') + }) + throws(t, 'ENOFSYNC', 'Fsync errors propagate', () => { + writeFileAtomicSync('nofsync', 'test') + }) + noexception(t, 'No errors on ENOSYS', () => { + writeFileAtomicSync('enosys', 'test', { chown: { uid: 100, gid: 100 } }) + }) + noexception(t, 'No errors on EINVAL for non root', () => { + writeFileAtomicSync('einval', 'test', { chown: { uid: 100, gid: 100 } }) + }) + noexception(t, 'No errors on EPERM for non root', () => { + writeFileAtomicSync('eperm', 'test', { chown: { uid: 100, gid: 100 } }) + }) + + throws(t, 'ENOCHMOD', 'Chmod failures propagate', () => { + writeFileAtomicSync('nochmod', 'test', { mode: 0o741 }) + }) + noexception(t, 'No errors on EPERM for non root', () => { + writeFileAtomicSync('eperm', 'test', { mode: 0o741 }) + }) + noexception(t, 'No attempt to chmod when no mode provided', () => { + writeFileAtomicSync('nochmod', 'test', { mode: false }) + }) + }) + + t.test('errors for root', t => { + const { getuid } = process + process.getuid = () => 0 + t.teardown(() => { + process.getuid = getuid + }) + t.plan(2) + throws(t, 'EINVAL', 'Chown error as root user', () => { + writeFileAtomicSync('einval', 'test', { chown: { uid: 100, gid: 100 } }) + }) + throws(t, 'EINVAL', 'Chmod error as root user', () => { + writeFileAtomicSync('einval', 'test', { mode: 0o741 }) + }) + }) +}) + +t.test('promises', async t => { + let tmpfile + closeCalled = 0 + expectClose = 0 + t.teardown(() => { + t.parent.equal(closeCalled, expectClose, 'promises closed all files') + closeCalled = 0 + expectClose = 0 + }) + + await writeFileAtomic('good', 'test', { + tmpfileCreated (gottmpfile) { + tmpfile = gottmpfile + }, + }) + t.match(tmpfile, /^good\.\d+$/, 'tmpfileCreated is called for success') + + await writeFileAtomic('good', 'test', { + tmpfileCreated () { + return Promise.resolve() + }, + }) + + await t.rejects(writeFileAtomic('good', 'test', { + tmpfileCreated () { + return Promise.reject(new Error('reject from tmpfileCreated')) + }, + })) + + await t.rejects(writeFileAtomic('good', 'test', { + tmpfileCreated () { + throw new Error('throw from tmpfileCreated') + }, + })) + + tmpfile = undefined + await t.rejects(writeFileAtomic('noopen', 'test', { + tmpfileCreated (gottmpfile) { + tmpfile = gottmpfile + }, + })) + t.equal(tmpfile, undefined, 'tmpfileCreated is not called on open failure') + + await t.rejects(writeFileAtomic('nowrite', 'test', { + tmpfileCreated (gottmpfile) { + tmpfile = gottmpfile + }, + })) + t.match(tmpfile, /^nowrite\.\d+$/, 'tmpfileCreated is called if failure is after open') +}) \ No newline at end of file diff --git a/test/write-file-atomic/concurrency.js b/test/write-file-atomic/concurrency.js new file mode 100644 index 0000000..cea27cc --- /dev/null +++ b/test/write-file-atomic/concurrency.js @@ -0,0 +1,190 @@ +'use strict' +const t = require('tap') +// defining mock for fs so its functions can be modified +const fs = { + realpath (filename, cb) { + return cb(null, filename) + }, + open (tmpfile, options, mode, cb) { + if (/noopen/.test(tmpfile)) { + return cb(new Error('ENOOPEN')) + } + cb(null, tmpfile) + }, + write (fd) { + const cb = arguments[arguments.length - 1] + if (/nowrite/.test(fd)) { + return cb(new Error('ENOWRITE')) + } + cb() + }, + fsync (fd, cb) { + if (/nofsync/.test(fd)) { + return cb(new Error('ENOFSYNC')) + } + cb() + }, + close (fd, cb) { + cb() + }, + chown (tmpfile, uid, gid, cb) { + if (/nochown/.test(tmpfile)) { + return cb(new Error('ENOCHOWN')) + } + cb() + }, + chmod (tmpfile, mode, cb) { + if (/nochmod/.test(tmpfile)) { + return cb(new Error('ENOCHMOD')) + } + cb() + }, + rename (tmpfile, filename, cb) { + if (/norename/.test(tmpfile)) { + return cb(new Error('ENORENAME')) + } + cb() + }, + unlink (tmpfile, cb) { + if (/nounlink/.test(tmpfile)) { + return cb(new Error('ENOUNLINK')) + } + cb() + }, + stat (tmpfile, cb) { + if (/nostat/.test(tmpfile)) { + return cb(new Error('ENOSTAT')) + } + cb() + }, + realpathSync (filename) { + return filename + }, + openSync (tmpfile) { + if (/noopen/.test(tmpfile)) { + throw new Error('ENOOPEN') + } + return tmpfile + }, + writeSync (fd) { + if (/nowrite/.test(fd)) { + throw new Error('ENOWRITE') + } + }, + fsyncSync (fd) { + if (/nofsync/.test(fd)) { + throw new Error('ENOFSYNC') + } + }, + closeSync () { }, + chownSync (tmpfile) { + if (/nochown/.test(tmpfile)) { + throw new Error('ENOCHOWN') + } + }, + chmodSync (tmpfile) { + if (/nochmod/.test(tmpfile)) { + throw new Error('ENOCHMOD') + } + }, + renameSync (tmpfile) { + if (/norename/.test(tmpfile)) { + throw new Error('ENORENAME') + } + }, + unlinkSync (tmpfile) { + if (/nounlink/.test(tmpfile)) { + throw new Error('ENOUNLINK') + } + }, + statSync (tmpfile) { + if (/nostat/.test(tmpfile)) { + throw new Error('ENOSTAT') + } + }, +} + +const writeFileAtomic = t.mock('../../lib/write-file-atomic.js', { + fs: fs, +}) + +// preserve original functions +const oldRealPath = fs.realpath +const oldRename = fs.rename + +t.test('ensure writes to the same file are serial', t => { + let fileInUse = false + const ops = 5 // count for how many concurrent write ops to request + t.plan(ops * 3 + 3) + fs.realpath = (...args) => { + t.notOk(fileInUse, 'file not in use') + fileInUse = true + oldRealPath(...args) + } + fs.rename = (...args) => { + t.ok(fileInUse, 'file in use') + fileInUse = false + oldRename(...args) + } + for (let i = 0; i < ops; i++) { + writeFileAtomic('test', 'test', err => { + if (err) { + t.fail(err) + } else { + t.pass('wrote without error') + } + }) + } + setTimeout(() => { + writeFileAtomic('test', 'test', err => { + if (err) { + t.fail(err) + } else { + t.pass('successive writes after delay') + } + }) + }, 500) +}) + +t.test('allow write to multiple files in parallel, but same file writes are serial', t => { + const filesInUse = [] + const ops = 5 + let wasParallel = false + fs.realpath = (filename, ...args) => { + filesInUse.push(filename) + const firstOccurence = filesInUse.indexOf(filename) + // check for another occurence after the first + t.equal(filesInUse.indexOf(filename, firstOccurence + 1), -1, 'serial writes') + if (filesInUse.length > 1) { + wasParallel = true + } // remember that a parallel operation took place + oldRealPath(filename, ...args) + } + fs.rename = (filename, ...args) => { + filesInUse.splice(filesInUse.indexOf(filename), 1) + oldRename(filename, ...args) + } + t.plan(ops * 2 * 2 + 1) + let opCount = 0 + for (let i = 0; i < ops; i++) { + writeFileAtomic('test', 'test', err => { + if (err) { + t.fail(err, 'wrote without error') + } else { + t.pass('wrote without error') + } + }) + writeFileAtomic('test2', 'test', err => { + opCount++ + if (opCount === ops) { + t.ok(wasParallel, 'parallel writes') + } + + if (err) { + t.fail(err, 'wrote without error') + } else { + t.pass('wrote without error') + } + }) + } +}) \ No newline at end of file diff --git a/test/write-file-atomic/integration.js b/test/write-file-atomic/integration.js new file mode 100644 index 0000000..bed2e0e --- /dev/null +++ b/test/write-file-atomic/integration.js @@ -0,0 +1,311 @@ +'use strict' +const fs = require('fs') +const path = require('path') +const t = require('tap') + +const workdir = path.join(__dirname, path.basename(__filename, '.js')) +let testfiles = 0 +function tmpFile () { + return path.join(workdir, 'test-' + (++testfiles)) +} + +function readFile (p) { + return fs.readFileSync(p).toString() +} + +function didWriteFileAtomic (t, expected, filename, data, options, callback) { + if (options instanceof Function) { + callback = options + options = null + } + if (!options) { + options = {} + } + const actual = {} + const writeFileAtomic = t.mock('../../lib/write-file-atomic.js', { + fs: Object.assign({}, fs, { + chown (chownFilename, uid, gid, cb) { + actual.uid = uid + actual.gid = gid + process.nextTick(cb) + }, + stat (statFilename, cb) { + fs.stat(statFilename, (err, stats) => { + if (err) { + return cb(err) + } + cb(null, Object.assign(stats, expected || {})) + }) + }, + }), + }) + return writeFileAtomic(filename, data, options, err => { + t.strictSame(actual, expected, 'ownership is as expected') + callback(err) + }) +} + +function didWriteFileAtomicSync (t, expected, filename, data, options) { + const actual = {} + const writeFileAtomic = t.mock('../../lib/write-file-atomic.js', { + fs: Object.assign({}, fs, { + chownSync (chownFilename, uid, gid) { + actual.uid = uid + actual.gid = gid + }, + statSync (statFilename) { + const stats = fs.statSync(statFilename) + return Object.assign(stats, expected || {}) + }, + }), + }) + writeFileAtomic.sync(filename, data, options) + t.strictSame(actual, expected) +} + +function currentUser () { + return { + uid: process.getuid(), + gid: process.getgid(), + } +} + +t.test('setup', t => { + fs.rmSync(workdir, { recursive: true, force: true }) + fs.mkdirSync(workdir, { recursive: true }) + t.end() +}) + +t.test('writes simple file (async)', t => { + t.plan(3) + const file = tmpFile() + didWriteFileAtomic(t, {}, file, '42', err => { + t.error(err, 'no error') + t.equal(readFile(file), '42', 'content ok') + }) +}) + +t.test('writes simple file with encoding (async)', t => { + t.plan(3) + const file = tmpFile() + didWriteFileAtomic(t, {}, file, 'foo', 'utf16le', err => { + t.error(err, 'no error') + t.equal(readFile(file), 'f\u0000o\u0000o\u0000', 'content ok') + }) +}) + +t.test('writes buffers to simple file (async)', t => { + t.plan(3) + const file = tmpFile() + didWriteFileAtomic(t, {}, file, Buffer.from('42'), err => { + t.error(err, 'no error') + t.equal(readFile(file), '42', 'content ok') + }) +}) + +t.test('writes TypedArray to simple file (async)', t => { + t.plan(3) + const file = tmpFile() + didWriteFileAtomic(t, {}, file, new Uint8Array([0x34, 0x32]), err => { + t.error(err, 'no error') + t.equal(readFile(file), '42', 'content ok') + }) +}) + +t.test('writes undefined to simple file (async)', t => { + t.plan(3) + const file = tmpFile() + didWriteFileAtomic(t, {}, file, undefined, err => { + t.error(err, 'no error') + t.equal(readFile(file), '', 'content ok') + }) +}) + +t.test('writes to symlinks without clobbering (async)', t => { + t.plan(5) + const file = tmpFile() + const link = tmpFile() + fs.writeFileSync(file, '42') + fs.symlinkSync(file, link) + didWriteFileAtomic(t, currentUser(), link, '43', err => { + t.error(err, 'no error') + t.equal(readFile(file), '43', 'target content ok') + t.equal(readFile(link), '43', 'link content ok') + t.ok(fs.lstatSync(link).isSymbolicLink(), 'link is link') + }) +}) + +t.test('runs chown on given file (async)', t => { + const file = tmpFile() + didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '42', { chown: { uid: 42, gid: 43 } }, err => { + t.error(err, 'no error') + t.equal(readFile(file), '42', 'content ok') + t.end() + }) +}) + +t.test('writes simple file with no chown (async)', t => { + t.plan(3) + const file = tmpFile() + didWriteFileAtomic(t, {}, file, '42', { chown: false }, err => { + t.error(err, 'no error') + t.equal(readFile(file), '42', 'content ok') + t.end() + }) +}) + +t.test('runs chmod on given file (async)', t => { + t.plan(5) + const file = tmpFile() + didWriteFileAtomic(t, {}, file, '42', { mode: parseInt('741', 8) }, err => { + t.error(err, 'no error') + const stat = fs.statSync(file) + t.equal(stat.mode, parseInt('100741', 8)) + didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '23', + { chown: { uid: 42, gid: 43 } }, chownErr => { + t.error(chownErr, 'no error') + }) + }) +}) + +t.test('run chmod AND chown (async)', t => { + t.plan(3) + const file = tmpFile() + didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '42', + { mode: parseInt('741', 8), chown: { uid: 42, gid: 43 } }, err => { + t.error(err, 'no error') + const stat = fs.statSync(file) + t.equal(stat.mode, parseInt('100741', 8)) + }) +}) + +t.test('does not change chmod by default (async)', t => { + t.plan(5) + const file = tmpFile() + didWriteFileAtomic(t, {}, file, '42', { mode: parseInt('741', 8) }, err => { + t.error(err, 'no error') + + didWriteFileAtomic(t, currentUser(), file, '43', writeFileError => { + t.error(writeFileError, 'no error') + const stat = fs.statSync(file) + t.equal(stat.mode, parseInt('100741', 8)) + }) + }) +}) + +t.test('does not change chown by default (async)', t => { + t.plan(6) + const file = tmpFile() + didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '42', + { chown: { uid: 42, gid: 43 } }, _setModeOnly) + + function _setModeOnly (err) { + t.error(err, 'no error') + + didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '43', + { mode: parseInt('741', 8) }, _allDefault) + } + + function _allDefault (err) { + t.error(err, 'no error') + + didWriteFileAtomic(t, { uid: 42, gid: 43 }, file, '43', _noError) + } + + function _noError (err) { + t.error(err, 'no error') + } +}) + +t.test('writes simple file (sync)', t => { + t.plan(2) + const file = tmpFile() + didWriteFileAtomicSync(t, {}, file, '42') + t.equal(readFile(file), '42') +}) + +t.test('writes simple file with encoding (sync)', t => { + t.plan(2) + const file = tmpFile() + didWriteFileAtomicSync(t, {}, file, 'foo', 'utf16le') + t.equal(readFile(file), 'f\u0000o\u0000o\u0000') +}) + +t.test('writes simple buffer file (sync)', t => { + t.plan(2) + const file = tmpFile() + didWriteFileAtomicSync(t, {}, file, Buffer.from('42')) + t.equal(readFile(file), '42') +}) + +t.test('writes simple TypedArray file (sync)', t => { + t.plan(2) + const file = tmpFile() + didWriteFileAtomicSync(t, {}, file, new Uint8Array([0x34, 0x32])) + t.equal(readFile(file), '42') +}) + +t.test('writes undefined file (sync)', t => { + t.plan(2) + const file = tmpFile() + didWriteFileAtomicSync(t, {}, file, undefined) + t.equal(readFile(file), '') +}) + +t.test('writes to symlinks without clobbering (sync)', t => { + t.plan(4) + const file = tmpFile() + const link = tmpFile() + fs.writeFileSync(file, '42') + fs.symlinkSync(file, link) + didWriteFileAtomicSync(t, currentUser(), link, '43') + t.equal(readFile(file), '43', 'target content ok') + t.equal(readFile(link), '43', 'link content ok') + t.ok(fs.lstatSync(link).isSymbolicLink(), 'link is link') +}) + +t.test('runs chown on given file (sync)', t => { + t.plan(1) + const file = tmpFile() + didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '42', { chown: { uid: 42, gid: 43 } }) +}) + +t.test('runs chmod on given file (sync)', t => { + t.plan(3) + const file = tmpFile() + didWriteFileAtomicSync(t, {}, file, '42', { mode: parseInt('741', 8) }) + const stat = fs.statSync(file) + t.equal(stat.mode, parseInt('100741', 8)) + didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '23', { chown: { uid: 42, gid: 43 } }) +}) + +t.test('runs chown and chmod (sync)', t => { + t.plan(2) + const file = tmpFile() + didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '42', + { mode: parseInt('741', 8), chown: { uid: 42, gid: 43 } }) + const stat = fs.statSync(file) + t.equal(stat.mode, parseInt('100741', 8)) +}) + +t.test('does not change chmod by default (sync)', t => { + t.plan(3) + const file = tmpFile() + didWriteFileAtomicSync(t, {}, file, '42', { mode: parseInt('741', 8) }) + didWriteFileAtomicSync(t, currentUser(), file, '43') + const stat = fs.statSync(file) + t.equal(stat.mode, parseInt('100741', 8)) +}) + +t.test('does not change chown by default (sync)', t => { + t.plan(3) + const file = tmpFile() + didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '42', { chown: { uid: 42, gid: 43 } }) + didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '43', { mode: parseInt('741', 8) }) + didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '44') +}) + +t.test('cleanup', t => { + fs.rmSync(workdir, { recursive: true, force: true }) + t.end() +}) \ No newline at end of file From e8a23d6d70fb2cffce083ec900af862201be3688 Mon Sep 17 00:00:00 2001 From: Muskaan Shraogi Date: Fri, 24 Apr 2026 15:26:51 +0530 Subject: [PATCH 3/3] feat: write-file-atomic pulled --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 452f449..77a9dc6 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "tap": "^16.0.1" }, "dependencies": { - "semver": "^7.7.4", + "semver": "^7.3.5", "signal-exit": "^4.1.0" }, "engines": {