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..79011f5 --- /dev/null +++ b/lib/write-file-atomic.js @@ -0,0 +1,269 @@ +'use strict' +module.exports = writeFile +module.exports.sync = writeFileSync +module.exports._getTmpname = getTmpname // for testing +module.exports._cleanupOnExit = cleanupOnExit + +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 { + 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) +} + +function cleanupOnExit (tmpfile) { + return () => { + try { + fs.unlinkSync(typeof tmpfile === 'function' ? tmpfile() : tmpfile) + } catch { + // ignore errors + } + } +} + +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 + } + } + + return false +} + +async function writeFileAsync (filename, data, options = {}) { + if (typeof options === 'string') { + options = { encoding: 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 + } + + 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 (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 { + const result = await promise + return callback(result) + } catch (err) { + return callback(err) + } + } + + return promise +} + +function writeFileSync (filename, data, options) { + if (typeof options === 'string') { + options = { encoding: options } + } else if (!options) { + options = {} + } + try { + 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 { + 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 + } + } + } + + if (options.mode) { + try { + fs.chmodSync(tmpfile, options.mode) + } catch (err) { + if (!isChownErrOk(err)) { + throw err + } + } + } + + 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() + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 6afd400..77a9dc6 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "tap": "^16.0.1" }, "dependencies": { - "semver": "^7.3.5" + "semver": "^7.3.5", + "signal-exit": "^4.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" 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