From db4f5fdd34efd6397a3018066814709ad6821e38 Mon Sep 17 00:00:00 2001 From: Mohamed Ahmed Date: Tue, 10 Nov 2026 19:27:02 +0200 Subject: [PATCH] fs: preserve exact nanoseconds in CopyUtimes on POSIX When copying files with preserveTimestamps: true, CopyUtimes previously converted the source timespec integers into an IEEE-754 double of seconds before calling uv_fs_utime. At current epoch values, this resulted in sub-millisecond precision loss. If the original nanoseconds sat near a half-millisecond boundary, the loss caused the JS destination millisecond to round differently than the source. This commit uses utimensat directly on POSIX to pass the exact tv_sec and tv_nsec integers from the source file to the destination, avoiding the floating-point conversion. Fixes: https://github.com/nodejs/node/issues/66861 Signed-off-by: Mohamed Ahmed Assisted-by: Antigravity --- src/node_file.cc | 17 +++++- ...p-sync-preserve-timestamps-nanoseconds.mjs | 53 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-fs-cp-sync-preserve-timestamps-nanoseconds.mjs diff --git a/src/node_file.cc b/src/node_file.cc index b96771217b8..df5e376f069 100644 --- a/src/node_file.cc +++ b/src/node_file.cc @@ -56,6 +56,8 @@ #ifdef _WIN32 #include #else +#include +#include #include #endif @@ -4830,10 +4832,22 @@ CpError CopyUtimes(const std::filesystem::path& src, } const uv_stat_t* const s = static_cast(req.ptr); + auto dest_file_path_str = ConvertPathToUTF8(dest); + +#ifdef __POSIX__ + struct timespec ts[2]; + ts[0].tv_sec = s->st_atim.tv_sec; + ts[0].tv_nsec = s->st_atim.tv_nsec; + ts[1].tv_sec = s->st_mtim.tv_sec; + ts[1].tv_nsec = s->st_mtim.tv_nsec; + if (utimensat(AT_FDCWD, dest_file_path_str.c_str(), ts, 0) != 0) { + return CpError::Uv( + uv_translate_sys_error(errno), "utime", dest_file_path_str); + } +#else const double source_atime = s->st_atim.tv_sec + s->st_atim.tv_nsec / 1e9; const double source_mtime = s->st_mtim.tv_sec + s->st_mtim.tv_nsec / 1e9; - auto dest_file_path_str = ConvertPathToUTF8(dest); int utime_result = uv_fs_utime(nullptr, &req, dest_file_path_str.c_str(), @@ -4843,6 +4857,7 @@ CpError CopyUtimes(const std::filesystem::path& src, if (is_uv_error(utime_result)) { return CpError::Uv(utime_result, "utime", dest_file_path_str); } +#endif return {}; } diff --git a/test/parallel/test-fs-cp-sync-preserve-timestamps-nanoseconds.mjs b/test/parallel/test-fs-cp-sync-preserve-timestamps-nanoseconds.mjs new file mode 100644 index 00000000000..b6dff294c4d --- /dev/null +++ b/test/parallel/test-fs-cp-sync-preserve-timestamps-nanoseconds.mjs @@ -0,0 +1,53 @@ +// This tests that cpSync with preserveTimestamps preserves exact nanosecond +// timestamps on POSIX when copying directories or overwriting files. +import * as common from '../common/index.mjs'; +import { nextdir } from '../common/fs.js'; +import tmpdir from '../common/tmpdir.js'; +import assert from 'node:assert'; +import { cpSync, mkdirSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +if (common.isWindows) { + common.skip('Nanosecond timestamp preservation via utimensat is POSIX-only'); +} + +tmpdir.refresh(); + +// 1. Recursive directory copy (exercises CopyDirRecursive -> CopyUtimes in C++) +const srcDir = nextdir(); +mkdirSync(srcDir, { recursive: true }); +const srcFile = join(srcDir, 'file.txt'); +writeFileSync(srcFile, 'hello'); + +const srcFileStat = statSync(srcFile, { bigint: true }); +const srcDirStat = statSync(srcDir, { bigint: true }); + +const destDir = nextdir(); +cpSync(srcDir, destDir, { + recursive: true, + preserveTimestamps: true, +}); + +const destFile = join(destDir, 'file.txt'); +const destFileStat = statSync(destFile, { bigint: true }); +const destDirStat = statSync(destDir, { bigint: true }); + +assert.strictEqual(destFileStat.mtimeNs, srcFileStat.mtimeNs); +assert.strictEqual(destFileStat.mtime.getTime(), srcFileStat.mtime.getTime()); +assert.strictEqual(destDirStat.mtimeNs, srcDirStat.mtimeNs); +assert.strictEqual(destDirStat.mtime.getTime(), srcDirStat.mtime.getTime()); + +// 2. Single-file overwrite with force: true (exercises CpSyncOverrideFile -> CopyUtimes in C++) +const singleDest = join(nextdir(), 'existing.txt'); +mkdirSync(join(singleDest, '..'), { recursive: true }); +writeFileSync(singleDest, 'old'); + +const updatedSrcFileStat = statSync(srcFile, { bigint: true }); +cpSync(srcFile, singleDest, { + force: true, + preserveTimestamps: true, +}); + +const singleDestStat = statSync(singleDest, { bigint: true }); +assert.strictEqual(singleDestStat.mtimeNs, updatedSrcFileStat.mtimeNs); +assert.strictEqual(singleDestStat.mtime.getTime(), updatedSrcFileStat.mtime.getTime());