fs: preserve directory timestamps in cp

This fixes an issue where fs.cp and fs.cpSync (when a filter is
provided) correctly restore mtime and atime on copied files when
preserveTimestamps: true is passed, but skip restoring them for
directories.

The fix applies the existing setDestTimestamps helper to directories,
ensuring it is called after directory creation/copying but before any
modes are restored (since restoring a read-only mode would prevent
timestamp modification).

Note: This fix covers fs.cp() (async, all cases) and fs.cpSync()
when a filter is provided. The native fast path used by cpSync()
without a filter (CpSyncCopyDir in src/node_file.cc) has the
identical gap and is left for a separate follow-up contribution, since
it requires native code changes and a different review path.

Signed-off-by: Abhinandan Kumar <abhi128618@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/65540
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: LiviaMedeiros <livia@cirno.name>
This commit is contained in:
Abhinandan Kumar authored and LiviaMedeiros committed 2026-08-31 17:03:33 +08:00
1 parent cb20bb3bad
commit 8fe4b642ea
4 files changed
+95 -1

No files matched your search

+3
View File
@@ -179,6 +179,9 @@ function copyDir(src, dest, opts, mkDir, srcMode) {
} finally {
dir.closeSync();
if (opts.preserveTimestamps) {
setDestTimestamps(src, dest);
}
if (srcMode !== undefined) {
setDestMode(dest, srcMode);
}
+7 -1
View File
@@ -311,12 +311,18 @@ async function onDir(srcStat, destStat, src, dest, opts) {
code: 'EEXIST',
});
}
return copyDir(src, dest, opts);
await copyDir(src, dest, opts);
if (opts.preserveTimestamps) {
await setDestTimestamps(src, dest);
}
}
async function mkDirAndCopy(srcMode, src, dest, opts) {
await mkdir(dest);
await copyDir(src, dest, opts);
if (opts.preserveTimestamps) {
await setDestTimestamps(src, dest);
}
return setDestMode(dest, srcMode);
}
@@ -0,0 +1,43 @@
// This tests that cp() preserves directory timestamps
// when preserveTimestamps is true.
import { mustCall } from '../common/index.mjs';
import { nextdir } from '../common/fs.js';
import assert from 'node:assert';
import { cp, mkdirSync, writeFileSync, utimesSync, statSync } from 'node:fs';
import { join } from 'node:path';
import tmpdir from '../common/tmpdir.js';
tmpdir.refresh();
// Build a source tree with a known past timestamp on directories.
const src = nextdir();
mkdirSync(join(src, 'subdir'), { recursive: true });
writeFileSync(join(src, 'subdir', 'file.txt'), 'hello');
const pastDate = new Date('2020-01-01T00:00:00Z');
utimesSync(join(src, 'subdir', 'file.txt'), pastDate, pastDate);
utimesSync(join(src, 'subdir'), pastDate, pastDate);
utimesSync(src, pastDate, pastDate);
// Copy with preserveTimestamps.
const dest = nextdir();
cp(src, dest, {
recursive: true,
preserveTimestamps: true,
}, mustCall((err) => {
assert.strictEqual(err, null);
// Verify file timestamps are preserved (existing behaviour).
const srcFileStat = statSync(join(src, 'subdir', 'file.txt'));
const destFileStat = statSync(join(dest, 'subdir', 'file.txt'));
assert.strictEqual(srcFileStat.mtime.getTime(), destFileStat.mtime.getTime());
// Verify directory timestamps are preserved (the bug fix).
const srcDirStat = statSync(join(src, 'subdir'));
const destDirStat = statSync(join(dest, 'subdir'));
assert.strictEqual(srcDirStat.mtime.getTime(), destDirStat.mtime.getTime());
const srcRootStat = statSync(src);
const destRootStat = statSync(dest);
assert.strictEqual(srcRootStat.mtime.getTime(), destRootStat.mtime.getTime());
}));
@@ -0,0 +1,42 @@
// This tests that cpSync with a filter preserves directory timestamps
// when preserveTimestamps is true.
import '../common/index.mjs';
import { nextdir } from '../common/fs.js';
import assert from 'node:assert';
import { cpSync, mkdirSync, writeFileSync, utimesSync, statSync } from 'node:fs';
import { join } from 'node:path';
import tmpdir from '../common/tmpdir.js';
tmpdir.refresh();
// Build a source tree with a known past timestamp on directories.
const src = nextdir();
mkdirSync(join(src, 'subdir'), { recursive: true });
writeFileSync(join(src, 'subdir', 'file.txt'), 'hello');
const pastDate = new Date('2020-01-01T00:00:00Z');
utimesSync(join(src, 'subdir', 'file.txt'), pastDate, pastDate);
utimesSync(join(src, 'subdir'), pastDate, pastDate);
utimesSync(src, pastDate, pastDate);
// Copy with preserveTimestamps and a filter (to exercise the JS fallback path).
const dest = nextdir();
cpSync(src, dest, {
recursive: true,
preserveTimestamps: true,
filter: () => true,
});
// Verify file timestamps are preserved (existing behaviour).
const srcFileStat = statSync(join(src, 'subdir', 'file.txt'));
const destFileStat = statSync(join(dest, 'subdir', 'file.txt'));
assert.strictEqual(srcFileStat.mtime.getTime(), destFileStat.mtime.getTime());
// Verify directory timestamps are preserved (the bug fix).
const srcDirStat = statSync(join(src, 'subdir'));
const destDirStat = statSync(join(dest, 'subdir'));
assert.strictEqual(srcDirStat.mtime.getTime(), destDirStat.mtime.getTime());
const srcRootStat = statSync(src);
const destRootStat = statSync(dest);
assert.strictEqual(srcRootStat.mtime.getTime(), destRootStat.mtime.getTime());