Skip to content

Commit 305a8e9

Browse files
committed
lib: normalize . and .. in path before rm/rmSync
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: #61958
1 parent 9145cc6 commit 305a8e9

File tree

3 files changed

+72
-1
lines changed

3 files changed

+72
-1
lines changed

lib/fs.js

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ const {
149149

150150
const permission = require('internal/process/permission');
151151

152+
// Matches a dotdot path component (e.g. `..`, `/../`, `../`) but not
153+
// filenames with consecutive dots like `a..b`.
154+
const dotdotRe = /(^|[\\/])\.\.(?:[\\/]|$)/;
155+
152156
let fs;
153157

154158
// Lazy loaded
@@ -1179,6 +1183,14 @@ function rm(path, options, callback) {
11791183
options = undefined;
11801184
}
11811185
path = getValidatedPath(path);
1186+
if (typeof path === 'string') {
1187+
if (SideEffectFreeRegExpPrototypeExec(dotdotRe, path) !== null)
1188+
path = pathModule.normalize(path);
1189+
} else if (isArrayBufferView(path)) {
1190+
const str = BufferToString(path, 'utf8');
1191+
if (SideEffectFreeRegExpPrototypeExec(dotdotRe, str) !== null)
1192+
path = pathModule.normalize(str);
1193+
}
11821194

11831195
validateRmOptions(path, options, false, (err, options) => {
11841196
if (err) {
@@ -1202,8 +1214,17 @@ function rm(path, options, callback) {
12021214
* @returns {void}
12031215
*/
12041216
function rmSync(path, options) {
1217+
path = getValidatedPath(path);
1218+
if (typeof path === 'string') {
1219+
if (SideEffectFreeRegExpPrototypeExec(dotdotRe, path) !== null)
1220+
path = pathModule.normalize(path);
1221+
} else if (isArrayBufferView(path)) {
1222+
const str = BufferToString(path, 'utf8');
1223+
if (SideEffectFreeRegExpPrototypeExec(dotdotRe, str) !== null)
1224+
path = pathModule.normalize(str);
1225+
}
12051226
const opts = validateRmOptionsSync(path, options, false);
1206-
return binding.rmSync(getValidatedPath(path), opts.maxRetries, opts.recursive, opts.retryDelay);
1227+
return binding.rmSync(path, opts.maxRetries, opts.recursive, opts.retryDelay);
12071228
}
12081229

12091230
/**

lib/internal/fs/promises.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const {
1717
Symbol,
1818
SymbolAsyncDispose,
1919
Uint8Array,
20+
uncurryThis,
2021
} = primordials;
2122

2223
const { fs: constants } = internalBinding('constants');
@@ -30,6 +31,7 @@ const {
3031

3132
const binding = internalBinding('fs');
3233
const { Buffer } = require('buffer');
34+
const BufferToString = uncurryThis(Buffer.prototype.toString);
3335

3436
const {
3537
AbortError,
@@ -92,6 +94,7 @@ const {
9294
promisify,
9395
isWindows,
9496
isMacOS,
97+
SideEffectFreeRegExpPrototypeExec,
9598
} = require('internal/util');
9699
const EventEmitter = require('events');
97100
const { StringDecoder } = require('string_decoder');
@@ -102,6 +105,10 @@ const assert = require('internal/assert');
102105

103106
const permission = require('internal/process/permission');
104107

108+
// Matches a dotdot path component (e.g. `..`, `/../`, `../`) but not
109+
// filenames with consecutive dots like `a..b`.
110+
const dotdotRe = /(^|[\\/])\.\.(?:[\\/]|$)/;
111+
105112
const kHandle = Symbol('kHandle');
106113
const kFd = Symbol('kFd');
107114
const kRefs = Symbol('kRefs');
@@ -802,6 +809,14 @@ async function ftruncate(handle, len = 0) {
802809

803810
async function rm(path, options) {
804811
path = getValidatedPath(path);
812+
if (typeof path === 'string') {
813+
if (SideEffectFreeRegExpPrototypeExec(dotdotRe, path) !== null)
814+
path = pathModule.normalize(path);
815+
} else if (isArrayBufferView(path)) {
816+
const str = BufferToString(path, 'utf8');
817+
if (SideEffectFreeRegExpPrototypeExec(dotdotRe, str) !== null)
818+
path = pathModule.normalize(str);
819+
}
805820
options = await validateRmOptionsPromise(path, options, false);
806821
return lazyRimRaf()(path, options);
807822
}

test/parallel/test-fs-rm.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,3 +569,38 @@ if (isGitPresent) {
569569
}
570570
}
571571
}
572+
573+
// Test that rm/rmSync normalize '.' and '..' in paths before processing.
574+
// Regression test for https://github.com/nodejs/node/issues/61958
575+
//
576+
// Each variant gets its own base directory to avoid async/sync races.
577+
// The weird path is constructed by joining components with path.sep so that
578+
// the '..' and '.' are preserved and not pre-normalized by path.join.
579+
{
580+
// --- rmSync: <base>/a/b/../. should remove <base>/a entirely ---
581+
const base = nextDirPath('dotdot-sync');
582+
fs.mkdirSync(path.join(base, 'a', 'b', 'c', 'd'), common.mustNotMutateObjectDeep({ recursive: true }));
583+
const weirdPath = [base, 'a', 'b', '..', '.'].join(path.sep);
584+
fs.rmSync(weirdPath, common.mustNotMutateObjectDeep({ recursive: true }));
585+
assert.strictEqual(fs.existsSync(path.join(base, 'a')), false);
586+
}
587+
588+
{
589+
// --- fs.rm (callback): same path construction ---
590+
const base = nextDirPath('dotdot-cb');
591+
fs.mkdirSync(path.join(base, 'a', 'b', 'c', 'd'), common.mustNotMutateObjectDeep({ recursive: true }));
592+
const weirdPath = [base, 'a', 'b', '..', '.'].join(path.sep);
593+
fs.rm(weirdPath, common.mustNotMutateObjectDeep({ recursive: true }), common.mustSucceed(() => {
594+
assert.strictEqual(fs.existsSync(path.join(base, 'a')), false);
595+
}));
596+
}
597+
598+
{
599+
// --- fs.promises.rm: same path construction ---
600+
const base = nextDirPath('dotdot-prom');
601+
fs.mkdirSync(path.join(base, 'a', 'b', 'c', 'd'), common.mustNotMutateObjectDeep({ recursive: true }));
602+
const weirdPath = [base, 'a', 'b', '..', '.'].join(path.sep);
603+
fs.promises.rm(weirdPath, common.mustNotMutateObjectDeep({ recursive: true })).then(common.mustCall(() => {
604+
assert.strictEqual(fs.existsSync(path.join(base, 'a')), false);
605+
}));
606+
}

0 commit comments

Comments
 (0)