lib: normalize . and .. in path before rm/rmSync#61968
lib: normalize . and .. in path before rm/rmSync#61968RajeshKumar11 wants to merge 1 commit intonodejs:mainfrom
Conversation
cd665e4 to
887b2c8
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #61968 +/- ##
==========================================
+ Coverage 88.84% 89.77% +0.93%
==========================================
Files 674 674
Lines 204957 205733 +776
Branches 39309 39445 +136
==========================================
+ Hits 182087 184693 +2606
+ Misses 15088 13289 -1799
+ Partials 7782 7751 -31
🚀 New features to boost your workflow:
|
887b2c8 to
9654815
Compare
isaacs
left a comment
There was a problem hiding this comment.
In general I think this is the right approach. Just one question about how it handles non-string inputs.
lib/internal/fs/promises.js
Outdated
|
|
||
| async function rm(path, options) { | ||
| path = getValidatedPath(path); | ||
| if (typeof path === 'string') path = pathModule.normalize(path); |
There was a problem hiding this comment.
What happens if it's not a string? Does rm allow non-string values (file:// urls, buffers, etc?) If so, what happens if they correspond to a ../. type of path?
There was a problem hiding this comment.
Good catch, thank you!
After getValidatedPath(), the path can be:
file:// URL inputs: fileURLToPath uses new URL(path), which normalizes .. during URL construction. They arrive as already-clean strings — our check does not even need to fire.
Buffer inputs: Our original typeof path === 'string' check was skipping these. The C++ binding (RmSync) calls ToNamespacedPath, which on Windows converts paths to \?\-prefixed extended-length form — and that form does not support .. components. So Buffer inputs with .. had the same problem as string inputs.
Fixed in the latest push: Buffer inputs are now also handled by converting to a UTF-8 string via BufferToString = uncurryThis(Buffer.prototype.toString), checking for .., and normalizing if found. The normalized string is then passed to the binding (which accepts both strings and Buffers).
9654815 to
9e032a9
Compare
When fs.rm, fs.rmSync, or fs.promises.rm receive a path containing '.' or '..' components (e.g. 'a/b/../.'), the sync and async implementations behaved differently: - rmSync (C++ binding): std::filesystem::remove_all deleted the directory's children, but when trying to remove the top-level entry the path became invalid (the '..' referred to a now-deleted directory), causing the parent directory to be left behind. - promises.rm / rm (JS rimraf): rmdir(2) on a path ending in '.' returns EINVAL on POSIX, so the operation failed immediately without removing anything. Fix by calling path.normalize() on string paths immediately after getValidatedPath(), before the path is passed to either rimraf or the C++ binding. This resolves '.' and '..' lexically (e.g. 'a/b/../.' becomes 'a'), giving both code paths a clean, unambiguous path to operate on. Fixes: nodejs#61958
9e032a9 to
9ce510e
Compare
Summary
fs.rmSync,fs.rm, andfs.promises.rmnow callpath.normalize()on string paths before passing them to either the C++ binding (rmSync) or the JS rimraf implementation (rm/promises.rm).or..componentsRoot cause
Two different code paths handle
rmin Node.js:rmSync(C++ —src/node_file.cc:1661): callsstd::filesystem::remove_all(file_path).With
file_path = "a/b/../.", C++ deletes the contents ofa(by resolving the path through the kernel), but when it then tries tormdir("a/b/../.")to remove the top-level entry, thebcomponent was already deleted, so the path can't be resolved → ENOENT → treated as success →a/is left behind.rm/promises.rm(JS rimraf —lib/internal/fs/rimraf.js): callsrmdir(2)on the original path first.On POSIX,
rmdir("a/b/../.")returnsEINVAL(trailing.is an invalid argument for rmdir). EINVAL is not handled by rimraf's retry logic → the operation fails immediately without removing anything.Neither behaviour is correct, and they disagree with each other.
Fix
Apply
path.normalize()to string paths right aftergetValidatedPath()in all three entry points:lib/fs.jsrm()if (typeof path === 'string') path = pathModule.normalize(path)lib/fs.jsrmSync()lib/internal/fs/promises.jsrm()path.normalize('a/b/../.')→'a', which both rimraf and the C++ binding handle without issue. Buffer paths are left unchanged (they are an edge case andpath.normalizeonly accepts strings).Test
Three new test cases added to
test/parallel/test-fs-rm.js:fs.rmSyncwith a../.path removes the target directory completelyfs.rm(callback) with the same pathfs.promises.rmwith the same pathEach case creates
<base>/a/b/c/d, applies rm to<base>/a/b/../.(which should resolve to<base>/a), and asserts that<base>/ano longer exists.Related
Fixes: #61958