'use strict' process.setMaxListeners(1000000); const _ = require('lodash') const fs = require('fs') const os = require('os') const path = require('path') const {test} = require('tap') const requireInject = require('require-inject') let expectClose = 0 let closeCalled = 0 let expectCloseSync = 0 let closeSyncCalled = 0 const createErr = code => Object.assign(new Error(code), { code }) let unlinked = [] const fsMock = Object.assign ( {}, fs, { /* ASYNC */ mkdir (filename, opts, cb) { return cb(null); }, 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')) if (/statful/.test(tmpfile)) return cb(null, fs.statSync('/')); cb() }, /* SYNC */ mkdirSync (filename) {}, realpathSync (filename, cb) { return filename }, openSync (tmpfile, options) { 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 (fd) { closeSyncCalled++ }, chownSync (tmpfile, uid, gid) { 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, mode) { 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, filename) { 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') if (/statful/.test(tmpfile)) return fs.statSync('/'); } }); const makeUnstableAsyncFn = function () { return function () { if ( Math.random () <= .75 ) { const code = _.shuffle ([ 'EMFILE', 'ENFILE', 'EAGAIN', 'EBUSY', 'EACCESS', 'EPERM' ])[0]; throw createErr ( code ); } return arguments[arguments.length -1](null, arguments[0]); }; }; const makeUnstableSyncFn = function ( fn ) { return function () { if ( Math.random () <= .75 ) { const code = _.shuffle ([ 'EMFILE', 'ENFILE', 'EAGAIN', 'EBUSY', 'EACCESS', 'EPERM' ])[0]; throw createErr ( code ); } return fn.apply(undefined, arguments) }; }; const fsMockUnstable = Object.assign ( {}, fsMock, { open: makeUnstableAsyncFn (), write: makeUnstableAsyncFn (), fsync: makeUnstableAsyncFn (), close: makeUnstableAsyncFn (), rename: makeUnstableAsyncFn (), openSync: makeUnstableSyncFn ( _.identity ), writeSync: makeUnstableSyncFn ( _.noop ), fsyncSync: makeUnstableSyncFn ( _.noop ), closeSync: makeUnstableSyncFn ( _.noop ), renameSync: makeUnstableSyncFn ( _.noop ) }); const {writeFile: writeFileAtomic, writeFileSync: writeFileAtomicSync} = requireInject('../dist', { fs: fsMock }); 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(28) 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.is(err && err.message, 'ENOOPEN', 'fs.open failures propagate') }) writeFileAtomic('nowrite', 'test', err => { t.is(err && err.message, 'ENOWRITE', 'fs.writewrite failures propagate') }) writeFileAtomic('nowrite', Buffer.from('test', 'utf8'), err => { t.is(err && err.message, 'ENOWRITE', 'fs.writewrite failures propagate for buffers') }) writeFileAtomic('nochown', 'test', { chown: { uid: 100, gid: 100 } }, err => { t.is(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.is(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.is(err && err.message, 'ENORENAME', 'Rename errors propagate') }) writeFileAtomic('norename nounlink', 'test', err => { t.is(err && err.message, 'ENORENAME', 'Failure to unlink the temp file does not clobber the original error') }) writeFileAtomic('nofsync', 'test', err => { t.is(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') }) const optionsImmutable = {}; writeFileAtomic('statful', 'test', optionsImmutable, err => { t.notOk(err); t.deepEquals(optionsImmutable, {}); }); const schedule = filePath => { t.is(filePath, 'good'); return new Promise ( resolve => { resolve ( () => { t.is(true,true); }); }); }; writeFileAtomic('good','test', {schedule}, err => { t.notOk(err); }); const tmpCreate = filePath => `.${filePath}.custom`; const tmpCreated = filePath => t.is ( filePath, '.good.custom' ); writeFileAtomic('good','test', {tmpCreate, tmpCreated}, err => { t.notOk(err) }) const longPath = path.join(os.tmpdir(),'.012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789.txt'); const {writeFile: writeFileAtomicNative} = requireInject('../dist', { fs }); writeFileAtomicNative(longPath,'test', err => { t.notOk(err) }) const pathMissingFolders = path.join(os.tmpdir(),String(Math.random()),String(Math.random()),String(Math.random()),'foo.txt'); writeFileAtomicNative(pathMissingFolders,'test', err => { t.notOk(err) }) }) t.test('errors for root', t => { const { getuid } = process process.getuid = () => 0 t.teardown(() => { process.getuid = getuid }) const {writeFile: writeFileAtomic} = requireInject('../dist', { fs: fsMock }); 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' }) }) }) }) test('unstable async tests', t => { t.plan(2); const {writeFile: writeFileAtomic} = requireInject('../dist', { fs: fsMockUnstable }); writeFileAtomic('good', 'test', err => { t.notOk(err, 'No errors occur when retryable errors are thrown') }) writeFileAtomic('good', 'test', { timeout: 0 }, err => { t.is(!!err.code, true, 'Retrying can be disabled') }) }); 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.is(shouldthrow, err && err.message, msg) } const noexception = function (t, msg, todo) { let err try { todo() } catch (e) { err = e } t.ifError(err, msg) } let tmpfile t.test('non-root', t => { t.plan(38) 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, 'tmpCreated is called on success', () => { writeFileAtomicSync('good', 'test', { tmpCreated (gottmpfile) { tmpfile = gottmpfile } }) t.match(tmpfile, /^good\.tmp-\w+$/, 'tmpCreated called for success') t.match(tmpfile, /^good\.tmp-\d{10}[a-f0-9]{6}$/, 'tmpCreated format') }) tmpfile = undefined throws(t, 'ENOOPEN', 'fs.openSync failures propagate', () => { writeFileAtomicSync('noopen', 'test', { tmpCreated (gottmpfile) { tmpfile = gottmpfile } }) }) t.is(tmpfile, undefined, 'tmpCreated not called for open failure') throws(t, 'ENOWRITE', 'fs.writeSync failures propagate', () => { writeFileAtomicSync('nowrite', 'test', { tmpCreated (gottmpfile) { tmpfile = gottmpfile } }) }) t.match(tmpfile, /^nowrite\.tmp-\w+$/, 'tmpCreated 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 }) }) const optionsImmutable = {}; noexception(t, 'options are immutable', () => { writeFileAtomicSync('statful', 'test', optionsImmutable) }) t.deepEquals(optionsImmutable, {}); const tmpCreate = filePath => `.${filePath}.custom`; const tmpCreated = filePath => t.is ( filePath, '.good.custom' ); noexception(t, 'custom temp creator', () => { writeFileAtomicSync('good', 'test', {tmpCreate, tmpCreated}) }) const path0 = path.join(os.tmpdir(),'atomically-test-0'); const tmpPath0 = path0 + '.temp'; noexception(t, 'temp files are purged on success', () => { const {writeFileSync: writeFileAtomicSync} = requireInject('../dist', { fs }); writeFileAtomicSync(path0, 'test', {tmpCreate: () => tmpPath0}) }) t.is(true,fs.existsSync(path0)); t.is(false,fs.existsSync(tmpPath0)); const path1 = path.join(os.tmpdir(),'atomically-test-norename-1'); const tmpPath1 = path1 + '.temp'; throws(t, 'ENORENAME', 'temp files are purged on error', () => { const {writeFileSync: writeFileAtomicSync} = requireInject('../dist', { fs: Object.assign ( {}, fs, { renameSync: fsMock.renameSync })}); writeFileAtomicSync(path1, 'test', {tmpCreate: () => tmpPath1}) }) t.is(false,fs.existsSync(path1)); t.is(false,fs.existsSync(tmpPath1)); const path2 = path.join(os.tmpdir(),'atomically-test-norename-2'); const tmpPath2 = path2 + '.temp'; throws(t, 'ENORENAME', 'temp files can also not be purged on error', () => { const {writeFileSync: writeFileAtomicSync} = requireInject('../dist', { fs: Object.assign ( {}, fs, { renameSync: fsMock.renameSync })}); writeFileAtomicSync(path2, 'test', {tmpCreate: () => tmpPath2,tmpPurge: false}) }) t.is(false,fs.existsSync(path2)); t.is(true,fs.existsSync(tmpPath2)); const longPath = path.join(os.tmpdir(),'.012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789.txt'); noexception(t, 'temp files are truncated', () => { const {writeFileSync: writeFileAtomicSync} = requireInject('../dist', { fs }); writeFileAtomicSync(longPath, 'test') }) const pathMissingFolders = path.join(os.tmpdir(),String(Math.random()),String(Math.random()),String(Math.random()),'foo.txt'); noexception(t, 'parent folders are created', () => { const {writeFileSync: writeFileAtomicSync} = requireInject('../dist', { fs }); writeFileAtomicSync(pathMissingFolders, 'test') }) }) t.test('errors for root', t => { const { getuid } = process process.getuid = () => 0 t.teardown(() => { process.getuid = getuid }) const {writeFileSync: writeFileAtomicSync} = requireInject('../dist', { fs: fsMock }); 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 }) }) }) }) test('unstable sync tests', t => { t.plan(2); const throws = function (t, msg, todo) { let err try { todo() } catch (e) { err = e } t.is(!!err.code, true, msg) } const noexception = function (t, msg, todo) { let err try { todo() } catch (e) { err = e } t.ifError(err, msg) } noexception(t, 'No errors occur when retryable errors are thrown', () => { const {writeFileSync: writeFileAtomicSync} = requireInject('../dist', { fs: fsMockUnstable }); writeFileAtomicSync('good', 'test') }) throws(t, 'retrying can be disabled', () => { const {writeFileSync: writeFileAtomicSync} = requireInject('../dist', { fs: fsMockUnstable }); writeFileAtomicSync('good', 'test', { timeout: 0 }) }) }); 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', { tmpCreated (gottmpfile) { tmpfile = gottmpfile } }) t.match(tmpfile, /^good\.tmp-\w+$/, 'tmpCreated is called for success') await writeFileAtomic('good', 'test', { tmpCreated (gottmpfile) { return Promise.resolve() } }) tmpfile = undefined await t.rejects(writeFileAtomic('noopen', 'test', { tmpCreated (gottmpfile) { tmpfile = gottmpfile } })) t.is(tmpfile, undefined, 'tmpCreated is not called on open failure') await t.rejects(writeFileAtomic('nowrite', 'test', { tmpCreated (gottmpfile) { tmpfile = gottmpfile } })) t.match(tmpfile, /^nowrite\.tmp-\w+$/, 'tmpCreated is called if failure is after open') })