You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Discrepancy between fs.rmSync and fs/promises.rm behavior with . and .. in paths #61958
The weird thing is that a/b/../.should resolve to just a, so it's weird that it only deletes up to b. It's almost as if the rmSync method is coalescing /../. into just /.
Also, if the . is not the last item, then it seems to be fine? a/b/.././b deletes the b folder, and a/b/.. deletes just b, but not a.
At least, it seems like the sync and asynchronous methods should either both fail, or both succeed, with the same error.
How often does it reproduce? Is there a required condition?
every time, see repro steps above
What is the expected behavior? Why is that the expected behavior?
sync and asynchronous rm methods should fail and succeed in the same way on the same input.
What do you see instead?
Some cases where rmSync succeeds, and fs/promises.rm fails.
Additional information
I have a fix that I could land in the upstream rimraf library, and I'd of course be happy to send a PR to node to bring it back into alignment, since there have also been some improvements in performance and Windows reliability.
But it'd be good to know what the intended behavior is, so that the errors can be made consistent with Node's intended design first.
Still reproduces on v26.7.0 (Linux x64). I went through the full matrix of dot-segment
paths rather than just the reported one, and the divergence is wider than the report
suggests — and in two cases both forms report success while removing different things.
Fixture is a/b/c/d in each case; options are { recursive: true, force: true }.
path
resolves to
fs.rmSync
fsPromises.rm
a/b/../.
a
ok, leaves a
EINVAL, removes nothing
a/b/..
a
ok, leaves a
ok, leaves a
a/.
a
throws an Error with no .code, leaves a
EINVAL, removes nothing
a/b/c/.
a/b/c
throws an Error with no .code, leaves a/b/c
EINVAL, removes nothing
a/b/c/../..
a
ok, leaves a
ok, leaves a a/b
a/b/c/d/../../..
a
ok, leaves a
ok, leaves a a/b a/b/c
a/b/.././b
a/b
correct
correct
./a
a
correct
correct
a/b
a/b
correct
correct
Only 3 of 9 are correct in either form. Two observations that I don't think have come up yet:
1. The async .. cases fail silently because the path invalidates its own resolution.
Tracing the fs calls lib/internal/fs/rimraf.js makes for rm('<root>/a/b/c/../..'):
rmdir(<root>/a/b/c/../..) -> ENOTEMPTY
readdir(<root>/a/b/c/../..) -> ok, ['b']
lstat(<root>/a/b/c/../../b) -> ok
... recursion removes <root>/a/b/c/d, then <root>/a/b/c
rmdir(<root>/a/b/c/../../b) -> ENOENT
rmdir(<root>/a/b/c/../..) -> ENOENT
=> resolves successfully, leaves a/ and a/b/ behind
_rmchildren() builds child paths by concatenating onto the unresolved path, so the
recursion deletes a/b/c — a component the literal path needs in order to resolve. Every
later call then fails ENOENT, and rimraf() treats ENOENT as "already gone"
(rimraf.js#L49), so the failure is reported as success. That is why the async form is off
by exactly the number of .. segments rather than failing outright.
2. rmSync throws an error without a code property for trailing /. (rows 3 and 4).
Everything else in fs carries a code, so this breaks err.code === '...' handling
regardless of what gets decided about the dot-segment semantics. Happy to split that out
into its own issue if it's cleaner to track separately.
On the open questions in #61968, since they bear on the fix:
Non-string inputs: Buffer paths behave identically to strings — same failures, same
rows. URL paths, though, are not merely consistent between the two forms, they are correct in every row: the WHATWG parser resolves dot segments before the path reaches fs, so file://<root>/a/b/../. arrives as <root>/a. That seems like the useful
precedent — normalizing string and Buffer input would make the three documented input
types (string | Buffer | URL) agree with one another, rather than introducing a new
behaviour for any of them.
Over-normalizing: path.normalize() leaves a..b, a/..b, a/b../c and ..a
untouched — .. is only a dot segment when it is a whole path component. So guarding the
normalize call is an optimization question rather than a correctness one.
I prototyped the normalization to check it actually covers the matrix, by wrapping the
public API rather than patching lib/ (no build needed): normalizing the path at the entry
point makes all 9 rows correct for both string and Buffer input, and makes the sync and
async forms agree — 18/18.
One implementation detail that matters for Buffer paths: normalizing via buf.toString() corrupts filenames that are not valid UTF-8, which is data loss in a
delete API.
latin1 maps bytes 0-255 to code points 0-255 bijectively, and path.normalize() only
inspects ASCII separators and dots, so Buffer.from(path.normalize(p.toString('latin1')), 'latin1') is byte-safe. Worth pinning with a test either way, since it is silent when
wrong.
The question I'd like guidance on before writing a patch: the underlying cause is that rmSync goes through binding.rmSync (std::filesystem::remove_all) while rm and fsPromises.rm use the JS rimraf. Normalizing the path in both entry points fixes this
particular class, but the two implementations will keep drifting. Is the intent to converge
them on one implementation, or to keep both and align behaviour at the edges?
Happy to do the work either way. #61968 has been stale since February and is scheduled for
auto-close; @RajeshKumar11, are you still on it? If not I'm glad to pick it up and carry
over your commit with credit.
Disclosure: I used an AI coding assistant while investigating this. Everything above comes
from scripts I ran locally against v26.7.0 and against a checkout of main, and every number
is reproducible; the trace is real fs call output, not a summary.
I'd like to work on this, since #61968 and #66472 were both closed without landing.
My plan is to build on the approach isaacs endorsed in #61968: normalize dot segments before removal so that rm and rmSync behave the same. I'll address the review points there (Buffer and file:// URL inputs, matching only real .. segments rather than names like a..b, the same check in both paths). Tests will cover the full path matrix from the comment above.
@RohithPariki, you mentioned you might reopen #66472. If you're planning to pick it back up, let me know and I'll step aside so we don't duplicate work.
@SANG404 Thanks for checking in! I had the complete fix ready with the full path matrix and byte-safe Buffer handling, but GitHub doesn't allow reopening #66472 after force-pushing the branch. I've opened #66673 as the replacement PR.
Activity
mimic node's intended behavior with weird paths
mimic node's intended behavior with weird paths
mimic node's intended behavior with weird paths
mimic node's intended behavior with weird paths
AmarWaqar-TSKLI commentedon Sep 7, 2026
Still reproduces on v26.7.0 (Linux x64). I went through the full matrix of dot-segment
paths rather than just the reported one, and the divergence is wider than the report
suggests — and in two cases both forms report success while removing different things.
Fixture is
a/b/c/din each case; options are{ recursive: true, force: true }.fs.rmSyncfsPromises.rma/b/../.aaa/b/..aaaa/.aErrorwith no.code, leavesaa/b/c/.a/b/cErrorwith no.code, leavesa/b/ca/b/c/../..aaa a/ba/b/c/d/../../..aaa a/b a/b/ca/b/.././ba/b./aaa/ba/bOnly 3 of 9 are correct in either form. Two observations that I don't think have come up yet:
1. The async
..cases fail silently because the path invalidates its own resolution.Tracing the
fscallslib/internal/fs/rimraf.jsmakes forrm('<root>/a/b/c/../..'):_rmchildren()builds child paths by concatenating onto the unresolved path, so therecursion deletes
a/b/c— a component the literal path needs in order to resolve. Everylater call then fails
ENOENT, andrimraf()treatsENOENTas "already gone"(rimraf.js#L49), so the failure is reported as success. That is why the async form is off
by exactly the number of
..segments rather than failing outright.2.
rmSyncthrows an error without acodeproperty for trailing/.(rows 3 and 4).Everything else in
fscarries acode, so this breakserr.code === '...'handlingregardless of what gets decided about the dot-segment semantics. Happy to split that out
into its own issue if it's cleaner to track separately.
On the open questions in #61968, since they bear on the fix:
Bufferpaths behave identically to strings — same failures, samerows.
URLpaths, though, are not merely consistent between the two forms, they arecorrect in every row: the WHATWG parser resolves dot segments before the path reaches
fs, sofile://<root>/a/b/../.arrives as<root>/a. That seems like the usefulprecedent — normalizing string and
Bufferinput would make the three documented inputtypes (
string | Buffer | URL) agree with one another, rather than introducing a newbehaviour for any of them.
path.normalize()leavesa..b,a/..b,a/b../cand..auntouched —
..is only a dot segment when it is a whole path component. So guarding thenormalize call is an optimization question rather than a correctness one.
I prototyped the normalization to check it actually covers the matrix, by wrapping the
public API rather than patching lib/ (no build needed): normalizing the path at the entry
point makes all 9 rows correct for both
stringandBufferinput, and makes the sync andasync forms agree — 18/18.
One implementation detail that matters for
Bufferpaths: normalizing viabuf.toString()corrupts filenames that are not valid UTF-8, which is data loss in adelete API.
latin1maps bytes 0-255 to code points 0-255 bijectively, andpath.normalize()onlyinspects ASCII separators and dots, so
Buffer.from(path.normalize(p.toString('latin1')), 'latin1')is byte-safe. Worth pinning with a test either way, since it is silent whenwrong.
The question I'd like guidance on before writing a patch: the underlying cause is that
rmSyncgoes throughbinding.rmSync(std::filesystem::remove_all) whilermandfsPromises.rmuse the JSrimraf. Normalizing the path in both entry points fixes thisparticular class, but the two implementations will keep drifting. Is the intent to converge
them on one implementation, or to keep both and align behaviour at the edges?
Happy to do the work either way. #61968 has been stale since February and is scheduled for
auto-close; @RajeshKumar11, are you still on it? If not I'm glad to pick it up and carry
over your commit with credit.
Disclosure: I used an AI coding assistant while investigating this. Everything above comes
from scripts I ran locally against v26.7.0 and against a checkout of main, and every number
is reproducible; the trace is real
fscall output, not a summary.fs: normalize trailing dot segments for rm
fs: normalize trailing dot segments for rm
fs: normalize dot segments in rm and rmSync
fs: normalize dot segments in rm and rmSync
SANG404 commentedon Oct 11, 2026
I'd like to work on this, since #61968 and #66472 were both closed without landing.
My plan is to build on the approach isaacs endorsed in #61968: normalize dot segments before removal so that
rmandrmSyncbehave the same. I'll address the review points there (Buffer andfile://URL inputs, matching only real..segments rather than names likea..b, the same check in both paths). Tests will cover the full path matrix from the comment above.@RohithPariki, you mentioned you might reopen #66472. If you're planning to pick it back up, let me know and I'll step aside so we don't duplicate work.
fs: normalize dot segments in rm and rmSync
RohithPariki commentedon Oct 12, 2026
@SANG404 Thanks for checking in! I had the complete fix ready with the full path matrix and byte-safe Buffer handling, but GitHub doesn't allow reopening #66472 after force-pushing the branch. I've opened #66673 as the replacement PR.