Repository navigation
Conversation
fs.cpSync, fs.cp and fs.promises.cp gave each copied file the times of its source and left every directory with the time of the copy. Node v26.12.1 gives a directory the times of its source too (nodejs/node#65540). copyDir is the one function that fills a directory, for a directory the copy creates and for one that exists. It now sets the times after the last entry, so both cases and the three forms are covered by one statement in each walker. Add node's tests for this: test-fs-cp-sync-preserve-timestamps-dir.mjs, test-fs-cp-async-preserve-timestamps-dir.mjs and test-fs-cp-sync-preserve-timestamps-force-false.mjs.
…ops at a directory Move the preserveTimestamps cases to cp-preserve-timestamps.test.ts, written with node:test so the same file runs under node. Add a directory reached through a link with dereference, a copy into a sticky directory of another user (EPERM from utimes, entries copied), and a filter that throws in cpSync. The root-only case in cp.test.ts now also checks that the walk stops: the parent of the failing directory is not stamped. Cut the two comments to one line each, with the node v26.12.1 lines.
|
Warning Review limit reachedYou've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Next included review available in 15 minutes. View limit details
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Status
|
|
Updated 2:48 AM PT - Oct 29th, 2026
❌ @robobun, your commit 8de7a43 has 7 failures in
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.d46decdc-1095-470f-b736-34c3e4b88428
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.df94fcfb-6a37-44c7-9740-1cb724be0120
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.0e39377d-4922-43a3-933e-c8fd3aecb7c4
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.b83fb6d8-5c22-403d-bd12-33ad1d4e9bb2
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.05587fb3-bc17-4668-b857-2ec0de21738a
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.b67a80a5-d4ad-40ac-bed7-3da037667273
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.13ea90ac-0c80-43c6-bb90-97afac1a242c
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.902f5549-96f8-4f35-994d-67a61859dd10
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.bc1f08c2-d6a9-4679-960a-f44021d8e4ee
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.45a132b7-3104-452e-8b1d-f4af853b667a
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.002b6120-2e0b-41d7-b598-7310af8568a2
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.3283f734-5a9e-44a4-ae65-8f20bff04cd4
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.48f6b171-2043-4b34-af03-9f8479ff945c
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.e9e2ef41-9cf6-4f28-9450-b80361ad1742
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.692e8155-a73e-419f-af1d-619cbfc11bdc
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.e4e92dc6-bb91-4b55-8019-3155075cd5b7
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.b9249c47-bc51-4aa8-9f1c-0c57aef94c30
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.0ef80b1f-1aa5-47fd-94d7-49918a02fe57
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.5f3bbbfd-fcab-4379-ac52-680c14a53558
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.53f32d61-1880-4671-a58b-00ad3ff20c0d
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.d16529b4-4d0b-4316-8aa9-0f1b2230548e
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.4545b11f-2ee5-4d7e-923a-e6ba7576d65b
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.54bc5900-30e4-4e17-855f-88a4c02597fa
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.d65e4540-322c-43b3-bcf6-026cf19148b3
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.fd9bf5ad-fa5a-4e64-875e-854d74850e4d
CapacityLimitExceededThis account has reached its capacity limit. Reach out to your account team for assistance.8e39d51d-7a35-4e79-8e7b-82b363a29c97🧪 To try this PR locally: bunx bun-pr 47255That installs a local version of the PR into your bun-47255 --bun |
There was a problem hiding this comment.
Beyond the inline findings, I also checked that a copy aborting inside the new utimes step leaving a freshly created directory with mkdir's default mode is not a divergence: node's mkDirAndCopy also runs copyDir (and its stamp) before chmod, so the order matches. Both walkers (cp-sync.ts, cp.ts, which the callback form also uses) receive the same statement, and neither has an ahead-of-time/ twin that would need the change.
Extended reasoning...
The change adds one preserveTimestamps guard to copyDir in each of the two JS cp walkers so directories receive the source's atime/mtime, plus node:test-based and Bun-harness tests; no security-sensitive surface is touched. Two confirmed findings are posted inline (a macOS clonefile fast path that can keep source mtimes and break the new negative test, and the behavioral change to EPERM on pre-existing directories owned by another uid), so a human should weigh them; this note only records what else was examined.
| if (!skipped) getStats(destStat, srcItem, destItem, opts); | ||
| } | ||
| // Not in a `finally` as in node's filter walk: a copy that throws keeps its times and mode, as in node's native cpSync. https://github.com/nodejs/node/blob/v26.12.1/lib/internal/fs/cp/cp-sync.js#L188-L197 | ||
| if (opts.preserveTimestamps) setDestTimestamps(src, dest); |
There was a problem hiding this comment.
🔴 Callers using preserveTimestamps into an existing destination directory they do not own now get EPERM after its entries were copied; the base copies and succeeds. copyDir at src/js/internal/fs/cp-sync.ts:502 (same at src/js/internal/fs/cp.ts:380) stamps every directory, including pre-existing ones, and utimes with explicit times needs the owner. The PR calls this accepted Node parity; with force: false it also rewrites every existing directory's mtime when nothing is copied. Fix: stamp only directories the copy creates (move the call into mkDirAndCopy in both walkers) or make a failed stamp of a pre-existing directory non-fatal, so root-owned world-writable dests keep working. [also at: src/js/internal/fs/cp.ts:380 - Users who run an idempotent cp/cpSync with force: false and preserveTimestamps: true into a destination on a read-only mount now get an EROFS rejection where the base branch returned success as a no-op. copyDir at src/js/internal/fs/cp.ts:380 and src/js/internal/fs/cp-sync.ts:502 call setDestTimestamps on every visited directory, including pre-existing ones that the walk wrote nothing into.]
Why this was flagged
Trigger: fs.cpSync, fs.cp or fs.promises.cp with { recursive: true, preserveTimestamps: true } where dest already exists and is owned by another uid but is writable; this arrives through onDir at src/js/internal/fs/cp-sync.ts:484 / src/js/internal/fs/cp.ts:361, which calls copyDir for an existing directory. copyDir copies the entries, then src/js/internal/fs/cp-sync.ts:502 (async twin src/js/internal/fs/cp.ts:380) calls setDestTimestamps, whose utimesSync at src/js/internal/fs/cp-sync.ts:470 fails with EPERM because utimensat with explicit times requires ownership. The error is thrown after the directory's entries were written, siblings after it are not copied, and the parent is not stamped; on the base branch the same call copies everything and returns normally. A second effect: with force: false into a fully populated destination, nothing is copied but every existing directory's mtime is overwritten with the source's. No guard in the walkers distinguishes a directory the copy created from one that pre-existed.
Verification: src/js/internal/fs/cp-sync.ts:484 onDir with an existing directory falls through to copyDir, which at :502 runs setDestTimestamps; utimensat(2) with explicit times requires the caller to be the file owner, so this throws EPERM after the entries were written. Identical path at src/js/internal/fs/cp.ts:380. On the base neither copyDir had this line, so the same copy completed successfully.
There was a problem hiding this comment.
This is the first item under Downsides in the description, and it is a choice a maintainer has to make. The facts, all measured: node v26.12.1 stamps a directory that existed before the copy and throws the same error at the same place (its onDir, unchanged on node's main today). Node's own tests only check directories that the copy creates, so they pass with either shape. The shape you propose (stamp in mkDirAndCopy only) is two moved lines. It never fails where main succeeds, and it leaves an existing destination directory with its own times, where node gives it the source's. I am weighing the shapes a second time (as pushed, created directories only, or stamp existing directories and not fail on them) and will post the outcome in this thread. I leave the thread open until then.
There was a problem hiding this comment.
The outcome of the second look: the shape stays as pushed, and the choice is now written out for a maintainer in the description ("Behavior changes, for a maintainer to rule on" and "The two shapes").
What decided it: with the stamp in mkDirAndCopy only, calls as plain as mkdir dest followed by cp, or a second copy into the same destination, report success and leave directories at the time of the copy, where node v26.12.1 gives them the source's times. The price of the shape as pushed is the set of calls in the table there. They succeed on main and now throw as on node: EPERM (EACCES on macOS) for an existing directory of another user, EROFS on a read-only filesystem. A copy with force: false into a complete destination now also sets directory times (4 change events from fs.watch for 4 directories, main 0).
I did not take the third shape (stamp an existing directory and not fail on it). It returns success after a stamp that was refused, and nothing else in the two walkers ignores a failed step that an option asked for.
The switch to your shape is two moved lines and a move of 15 test cases. I leave this thread open for the maintainer who rules on it.
There was a problem hiding this comment.
One correction to my two replies above. The shape with the stamp in mkDirAndCopy only can also fail where main succeeds.
After the entries of a directory, the stamp reads the source directory again with stat. If that source directory is gone by then, the copy throws ENOENT (syscall stat). A filter that moves a directory at its last entry does this on every run. A second process that cleans the source during the copy can do it too. A directory that the copy creates is stamped in both shapes, so both shapes have this failure.
Measured with preserveTimestamps: true, the same result in cpSync, promises.cp and callback cp:
the copy creates dest/a |
dest/a exists |
|
|---|---|---|
| node v26.12.1 | ENOENT |
ENOENT |
| as pushed | ENOENT |
ENOENT |
stamp in mkDirAndCopy only (measured with the statement moved) |
ENOENT |
completes |
| main's walkers, bun 1.4.3-canary.1, node v26.3.0 | completes | completes |
The description has it now: the first item under Downsides, and change 3 in the Notes with the repro. It gives no reason to switch shapes, because a created directory fails the same way in both. The choice between the shapes stays with a maintainer.
Without a filter, Bun on macOS makes a new destination with one clonefile(2), and a clone keeps the times of the source. The case that checks that directories are not stamped without preserveTimestamps now passes a filter for the new destination, so it runs the walker on every platform.
…ectory utimes with explicit times on a directory of another user fails with EPERM on Linux and with EACCES on macOS.
There was a problem hiding this comment.
I re-reviewed the two newer commits and found no bugs in this revision; a human should still weigh in, because the production change deliberately picks one of two behaviors (stamping pre-existing destination directories as Node v26.9+ does, versus only the directories the copy creates) and that is a compatibility policy call rather than a correctness question.
What was reviewed:
- The two
copyDiradditions:preserveTimestampsalways routes to the JS walkers (src/js/node/fs.ts:1007,src/js/node/fs.promises.ts:177), and inmkDirAndCopythe stamp lands before the finalchmod, so a read-only source mode cannot blockutimeson a directory the copy owns. - The no-option negative test now uses a filter for the fresh destination and a pre-existing destination for the other copy, so neither can take the darwin
clonefilepath; the earlier macOS concern is addressed. - The root-only fixture in
cp.test.ts:unprivilegedSpawnOptionschowns the tree to nobody first, thento-*/ais chowned back to root at 0777, so the file copy succeeds and only theutimeon that directory fails;syscall: "utime"matchesnode_fs.rs:8648.
Extended reasoning...
The diff adds one guarded setDestTimestamps call to copyDir in both the sync and async cp walkers under src/js/internal/fs/, plus ~450 lines of tests (a node:test-portable file, a root-gated subprocess case in cp.test.ts, and three Node-style parallel tests). It touches no security-sensitive surface; the new failure modes are EPERM/EACCES/EROFS from utimes on directories the caller does not own, which the PR description itself lists as a downside. The two commits since the prior review are test-only (the clonefile fix and the macOS EACCES expectation); the production lines are unchanged, so the earlier inline thread about stamping pre-existing directories remains the open design question for a maintainer. Deferring rather than approving because that behavior choice is intentional, user-visible, and not something an automated check can settle.
Still open from earlier reviews (1):
- 🔴
src/js/internal/fs/cp-sync.ts:502—Callers using preserveTimestamps into an existing destination directory they do not own now get EPERM after its entries…
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
Problem
fs.cpSync,fs.cpandfs.promises.cpwithpreserveTimestamps: truekeep the times of files. Each directory gets the time of the copy. Node v26.12.1, whichprocess.versionreports, keeps directory times too (fs: preserve directory timestamps in cp nodejs/node#65540).src/js/internal/fs/cp.ts:308,src/js/internal/fs/cp-sync.ts:441).Fix
copyDirsets the times of a directory after its last entry, in both walkers. Created and existing directories both pass through it, in all three forms.test/js/node/fs/cp-preserve-timestamps.test.ts(40 cases, 33 fail on main, all pass on node v26.12.1), a root-only case incp.test.ts, node's two-preserve-timestamps-dirtests.cpSyncerror withoutrecursive(separate work) and new pins for the walkers' header links (unrelated).Background
cpruns in two JS walkers, a port of node'slib/internal/fs/cp.Downsides
EPERM(another user's directory,EACCESon macOS) orEROFS. It stops withENOENTfromstatwhen a source directory goes away after the copy handled its entries. Node v26.12.1 does the same, and main completes the copy.cpSync, +4 incp, no new call, cell or syscall. With it: +1stat, +1utimesper directory.Notes
Where this comes from. No Bun user reported it. An automated comparison with node v26.12.1 found it. The PR makes three of node's v26.12.1 test files pass. It is not a sync of
node:fsto v26.12.1.Repro (Linux x64, same result in the three forms):
A probe of 39 rows (three forms, with
filter,dereference,verbatimSymlinks,force: false,mode, an existing destination tree, a rejected sub-directory, a link to a directory, a read-only directory) differs from node v26.12.1 in 34 rows on main and in 1 row with this PR. That row is the error ofcpSyncon a directory withoutrecursive, which this PR does not touch.What node does. Node stamps after
copyDirinonDirand inmkDirAndCopyofcp.js, in afinallyofcopyDirincp-sync.js(the walk it uses with afilter), and in its C++ walk.copyDiris the one function both directory arms of each Bun walker call, so one statement there covers the same cases. Order per directory (call log): entries,utimes(dir),chmod(dir)for a directory the copy creates. Entries,utimes(dir), nochmodfor one that exists. Child before parent, root last.Behavior changes, for a maintainer to rule on. The first two come from one choice: a directory that existed in the destination before the copy is stamped too, as node v26.12.1 does (
onDirincp.js, lines 306 to 309, the same on node's main today). The third comes from the stamp itself, and a directory that the copy creates has it too.force: falseinto a complete destination wrote nothing on main. It now sets the times of each directory, and a watcher on the destination sees it: 4changeevents fromfs.watchfor a tree of 4 directories (main 0).utimeswith explicit times needs the owner of the directory (utimensat(2)). XNU answers another user withEACCESwhere Linux answersEPERM(vnode_authattr).aof another user (mode 0777), caller is uid 65534EPERM(EACCESon macOS, seen in CI), syscallutime, pathdest/a. The entries ofaare copied, the 8 siblings after it are not, the parent is not stampedakeeps its time/tmpa, caller is rootEPERMutimedest/a, its entry is copiedforce: false, nothing to writeEROFSutimedest/aWhich entries are copied before the stop depends on the order of the directory. A retry stops at the same place. Without the option the copy completes.
ENOENTwhen a source directory goes away after the copy handled its entries. These calls complete on bun 1.4.3-canary.1 and with main's walkers. After the entries of a directory, the stamp reads the source directory again withstat, and thatstatfails. Afilterthat moves a directory at its last entry does this on every run. A second process that cleans the source during the copy can do it too.fs.promises.cpand callbackcpdo the same. So does adest/athat exists before the copy. Without the option, or when nothing moves, all five complete the copy. Runs: the three forms 10 times each on node v26.12.1, on this PR and on main's walkers (this branch built with main'scp.tsandcp-sync.ts), one output per runtime. No test pins this case.The second
statis node's (setDestTimestamps): the copy gets the atime that the source has after the copy read it. Measured on ext4 withrelatime, node v26.12.1 and this PR: a source directory with both times in 2001 gives a copy with mtime 2001 and the atime of the copy. A stamp from thestatthat the walker already has would not fail here, and it would give atime 2001, which node does not.The two shapes. Node's tests assert the times of directories that the copy creates. None asserts a directory that existed before the copy, so both shapes pass them.
copyDir, which both directory arms call. Node's result where node succeeds, node's error where it fails. A second look at the design, with the failures of change 2 counted, kept this shape: the other one reports success and leaves directories unpreserved in calls as plain asmkdir destfollowed bycp, or a second copy into the same destination.copyDirintomkDirAndCopy, betweencopyDirandsetDestMode. Changes 1 and 2 do not happen. Change 3 stays for a directory that the copy creates (measured with the statement moved:ENOENTin the three forms). An existing destination directory keeps its own times, where node gives it the source's. The 12 cases for an existing destination and the 3 for a sticky directory would then move to Bun-only tests.If the second shape is wanted, say so and I will switch. It is two moved lines and the test move.
Still different from node.
cpSyncwith afilterthat throws part-way: node's filter walk stamps and chmods the directories it unwinds through (thefinally). Bun does neither and throws the filter's error, as node's three other walks do. A test pins the error.setDestTimestampsgoes throughDate). node:fs: copy file times natively and exactly in cp with preserveTimestamps #41868 makes file times exact with a native step that also sets a mode, and it deletessetDestTimestamps. It says directories are separate work. When it is rebased over this PR, it has to keepsetDestTimestampsfor the twocopyDircalls or add a step that sets times only. Until then a copied directory can read up to 1 ms older than its source.clonefile(2)when the copy has no filter and the tree holds only files and directories. A clone keeps every time, so there the directories have the source's times with or without the option. Node gives them the time of the copy. This PR does not change that path.onLinkpasses, Windows). The error ofcpSyncon a directory withoutrecursive(node throws from a C++ check with another message and nopath).Cost (release builds of main
11c367163aand the branch, Linux x64):cpSynccopyDir(get_by_id,jfalse), +4 incp/promises.cpcopyDir. The tail calls of the sync walker stay (BUN_JSC_dumpGeneratedBytecodes).node:fscalls per copy of 17 directories and 256 files: 1,383 = 1,383 without the option. With it 1,895 -> 1,929, +2 per directory (call counter over thenode:fsexports).cpSync222 = 222 without the option, 254 -> 264 with it.promises.cp227 = 227 and 259 -> 269 (statx+5,utimensat+5, gdbcatch syscall).cpSync, +14 cells and +5 promises inpromises.cp(heapStatsslope between 100 and 200 directories,BUN_JSC_useGC=0).internal/fs/cp.js+71 bytes,internal/fs/cp-sync.js+65. No host function, no native file in the diff. The walkers load on first use, so start-up does not change.Other open PRs in these files. #47102 adds an early
returnin the synccopyDirfor an empty directory, and #41112 adds one in the asynccopyDir. Both merge above the new statement with no conflict, and then skip it. The empty-directory rows of the new tests fail for #47102 in that case.Tests.
cp-preserve-timestamps.test.tsusesnode:testandnode:assert, so the same file runs under node. ForcpSync,promises.cpand callbackcp: a new destination, an existing destination tree (its directories get the source's times, a directory keeps its mode, a file the source does not have keeps its time), each also withfilter,dereferenceandforce: false. A directory reached through a link withdereference. A sub-directory that the filter rejects. Parents created above the destination. No stamp without the option. A copy into a sticky directory of another user (runs when the directory above the temporary directory is one). Afilterthat throws incpSync. Onlymtimeis asserted.cp.test.tshas the root-only case with a directory of another user inside the destination. It also checks that the parent is not stamped.test-fs-cp-sync-preserve-timestamps-dir.mjsandtest-fs-cp-async-preserve-timestamps-dir.mjs(both fail on main) andtest-fs-cp-sync-preserve-timestamps-force-false.mjs, unedited.test-fs-cp*node tests andtest/js/node/fs/.EACCES), and the cases now allow for both.no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/fs/cp.test.ts, test/js/node/fs/cp-preserve-timestamps.test.ts