From cb2e8b711920ff427582214e39b49713617a7971 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 11 Sep 2026 15:26:49 +0100 Subject: [PATCH 1/2] fix: keep tmpfile names from exceeding the destination name Appending a unique suffix to a basename that already saturates the filesystem NAME_MAX made the tmpfile unopenable (ENAMETOOLONG). Replace the end of the basename with the suffix so a legal destination name is also a legal tmp name, including on filesystems whose NAME_MAX is not 255. Fixes #63 --- README.md | 3 +-- lib/index.js | 8 +++++++- test/basic.js | 17 +++++++++++++++-- test/integration.js | 28 ++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2d9ef60..9ac34e4 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,7 @@ var writeFileAtomic = require('write-file-atomic') writeFileAtomic(filename, data, [options], [callback]) ``` -The file is initially named `filename + "." + murmurhex(__filename, process.pid, ++invocations)`. -Note that `require('worker_threads').threadId` is used in addition to `process.pid` if running inside of a worker thread. +The file is initially written to a temporary name in the same directory. That name is the destination plus a unique numeric suffix from a hash of this module's path, `process.pid`, worker `threadId` (when running in a worker thread), and an invocation counter. If appending the suffix would make the tmpfile basename longer than the destination basename, the suffix replaces the end of the basename instead, so a destination name that is legal on the filesystem cannot produce `ENAMETOOLONG` for the tmpfile. If writeFile completes successfully then, if passed the **chown** option it will change the ownership of the file. Finally it renames the file back to the filename you specified. If it encounters errors at any of these steps it will attempt to unlink the temporary file and then diff --git a/lib/index.js b/lib/index.js index d470cdd..3d8f4cf 100644 --- a/lib/index.js +++ b/lib/index.js @@ -27,7 +27,7 @@ const threadId = (function getId () { let invocations = 0 function getTmpname (filename) { - return filename + '.' + + const suffix = '.' + crypto.createHash('sha1') .update(__filename) .update(String(process.pid)) @@ -35,6 +35,12 @@ function getTmpname (filename) { .update(String(++invocations)) .digest() .readUInt32BE(0) + // Never lengthen the basename: appending overflows NAME_MAX on long names (#63). + const basename = path.basename(filename) + if (basename.length <= suffix.length) { + return filename + suffix + } + return filename.slice(0, filename.length - suffix.length) + suffix } function cleanupOnExit (tmpfile) { diff --git a/test/basic.js b/test/basic.js index 264dff0..5cb6f18 100644 --- a/test/basic.js +++ b/test/basic.js @@ -161,6 +161,19 @@ t.test('getTmpname', t => { const a = getTmpname('abc.def') const b = getTmpname('abc.def') t.not(a, b, 'different invocations of getTmpname get different results') + t.match(a, /^abc\.def\.\d+$/, 'short names keep an appended unique suffix') + + const longBase = 'a'.repeat(255) + const longFile = '/tmp/' + longBase + const longTmp = getTmpname(longFile) + t.equal( + longTmp.length, + longFile.length, + 'tmp path is no longer than the destination when the basename is already long' + ) + t.equal(longTmp.slice(0, '/tmp/'.length), '/tmp/', 'tmp stays in the destination directory') + t.match(longTmp, /\.\d+$/, 'tmp still ends with a unique numeric suffix') + t.not(getTmpname(longFile), longTmp, 'long names still get unique tmp names') t.end() }) @@ -226,7 +239,7 @@ t.test('async tests', t => { writeFileAtomic('norename', 'test', err => { t.equal(err && err.message, 'ENORENAME', 'Rename errors propagate') }) - writeFileAtomic('norename nounlink', 'test', err => { + writeFileAtomic('norename nounlink ' + 'x'.repeat(32), 'test', err => { t.equal(err && err.message, 'ENORENAME', 'Failure to unlink the temp file does not clobber the original error') }) @@ -352,7 +365,7 @@ t.test('sync tests', t => { }) throws(t, 'ENORENAME', 'Failure to unlink the temp file does not clobber the original error', () => { - writeFileAtomicSync('norename nounlink', 'test') + writeFileAtomicSync('norename nounlink ' + 'x'.repeat(32), 'test') }) throws(t, 'ENOFSYNC', 'Fsync errors propagate', () => { writeFileAtomicSync('nofsync', 'test') diff --git a/test/integration.js b/test/integration.js index 83442c1..578adf3 100644 --- a/test/integration.js +++ b/test/integration.js @@ -305,6 +305,34 @@ t.test('does not change chown by default (sync)', t => { didWriteFileAtomicSync(t, { uid: 42, gid: 43 }, file, '44') }) +t.test('writes a file at the filesystem name length limit (sync)', t => { + const writeFileAtomic = require('..') + const file = path.join(workdir, 'x'.repeat(255)) + let tmpfile + writeFileAtomic.sync(file, '42', { + tmpfileCreated (tmp) { + tmpfile = tmp + }, + }) + t.equal(path.basename(tmpfile).length, 255, 'tmp basename is no longer than the target') + t.equal(readFile(file), '42', 'content ok') + t.end() +}) + +t.test('writes a file at the filesystem name length limit (async)', t => { + const writeFileAtomic = require('..') + const file = path.join(workdir, 'y'.repeat(255)) + writeFileAtomic(file, '43', { + tmpfileCreated (tmpfile) { + t.equal(path.basename(tmpfile).length, 255, 'tmp basename is no longer than the target') + }, + }, err => { + t.error(err, 'no error') + t.equal(readFile(file), '43', 'content ok') + t.end() + }) +}) + t.test('cleanup', t => { fs.rmSync(workdir, { recursive: true, force: true }) t.end() From 53c6e8bfbae03cea23fda5ca5a246117015ffeb6 Mon Sep 17 00:00:00 2001 From: Christopher Pruijsen Date: Fri, 11 Sep 2026 15:38:38 +0100 Subject: [PATCH 2/2] fix: decide tmp truncation using max suffix width Comparing against the actual uint32 decimal width made names of length 3-11 append or truncate depending on the hash. Use the maximum width (11) so the choice is stable, while still never lengthening names that can hit NAME_MAX. --- README.md | 2 +- lib/index.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9ac34e4..be7fdb8 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ var writeFileAtomic = require('write-file-atomic') writeFileAtomic(filename, data, [options], [callback]) ``` -The file is initially written to a temporary name in the same directory. That name is the destination plus a unique numeric suffix from a hash of this module's path, `process.pid`, worker `threadId` (when running in a worker thread), and an invocation counter. If appending the suffix would make the tmpfile basename longer than the destination basename, the suffix replaces the end of the basename instead, so a destination name that is legal on the filesystem cannot produce `ENAMETOOLONG` for the tmpfile. +The file is initially written to a temporary name in the same directory. That name is the destination plus a unique numeric suffix from a hash of this module's path, `process.pid`, worker `threadId` (when running in a worker thread), and an invocation counter. If the destination basename is longer than 11 characters (the longest `'.'`+uint32 suffix), the suffix replaces the end of the basename instead, so a destination name that is legal on the filesystem cannot produce `ENAMETOOLONG` for the tmpfile. If writeFile completes successfully then, if passed the **chown** option it will change the ownership of the file. Finally it renames the file back to the filename you specified. If it encounters errors at any of these steps it will attempt to unlink the temporary file and then diff --git a/lib/index.js b/lib/index.js index 3d8f4cf..c50535c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -36,8 +36,9 @@ function getTmpname (filename) { .digest() .readUInt32BE(0) // Never lengthen the basename: appending overflows NAME_MAX on long names (#63). + // uint32 suffix is 2-11 chars; use the max so the choice does not depend on hash width. const basename = path.basename(filename) - if (basename.length <= suffix.length) { + if (basename.length <= 11) { return filename + suffix } return filename.slice(0, filename.length - suffix.length) + suffix