Skip to content

Commit 09a4064

Browse files
fs: normalize trailing dot segments for rm
fs.rmSync() dispatches to std::filesystem::remove_all() while fs.rm() and fsPromises.rm() walk the tree in JavaScript, so the two forms disagree whenever the trailing path component is `.` or `..`. rmSync() removes the contents of the resolved target but leaves the target itself behind, and for a trailing `.` it throws an error carrying no code property. The promise form rejects with EINVAL for a trailing `.`, and for a trailing `..` it reports success while removing a directory below the one that was requested. The silent case happens because _rmchildren() builds child paths by concatenating onto the unresolved path. The walk removes a directory that an unresolved `..` still needs in order to resolve, so every later operation fails with ENOENT, which rimraf() treats as already deleted. Resolve a trailing dot segment at the three entry points so both forms operate on the same path. Only a trailing `.` or `..` is rewritten, since that is where the two implementations diverge; every other path is passed through unchanged, so paths that already behaved correctly keep their existing behaviour, including the resource string the permission model reports for them. Buffer paths go through latin1 rather than utf8 because filenames are arbitrary byte sequences, and a utf8 round trip rewrites invalid sequences to U+FFFD, which would remove a different path than the one requested. Fixes: #61958 Signed-off-by: AmarWaqar-TSKLI <amarwaqar15@gmail.com>
1 parent 10720f7 commit 09a4064

4 files changed

Lines changed: 254 additions & 3 deletions

File tree

lib/fs.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ const {
109109
getValidatedFd,
110110
getValidatedPath,
111111
handleErrorFromBinding,
112+
normalizeRmPath,
112113
preprocessSymlinkDestination,
113114
Stats,
114115
getReadFileBuffer,
@@ -1533,7 +1534,7 @@ function rm(path, options, callback) {
15331534
const h = vfsState.handlers;
15341535
if (h !== null && vfsVoid(h.rm(path, options), callback)) return;
15351536

1536-
path = getValidatedPath(path);
1537+
path = normalizeRmPath(getValidatedPath(path));
15371538

15381539
validateRmOptions(path, options, false, (err, options) => {
15391540
if (err) {
@@ -1562,8 +1563,9 @@ function rmSync(path, options) {
15621563
const result = h.rmSync(path, options);
15631564
if (result !== undefined) return;
15641565
}
1566+
path = normalizeRmPath(getValidatedPath(path));
15651567
const opts = validateRmOptionsSync(path, options, false);
1566-
return binding.rmSync(getValidatedPath(path), opts.maxRetries, opts.recursive, opts.retryDelay);
1568+
return binding.rmSync(path, opts.maxRetries, opts.recursive, opts.retryDelay);
15671569
}
15681570

15691571
/**

lib/internal/fs/promises.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const {
7171
getValidatedPath,
7272
getReadFileBuffer,
7373
getReadFileBufferByteLengthName,
74+
normalizeRmPath,
7475
preprocessSymlinkDestination,
7576
stringToFlags,
7677
stringToSymlinkType,
@@ -1548,7 +1549,7 @@ async function rm(path, options) {
15481549
const promise = h.rm(path, options);
15491550
if (promise !== undefined) { await promise; return; }
15501551
}
1551-
path = getValidatedPath(path);
1552+
path = normalizeRmPath(getValidatedPath(path));
15521553
options = await validateRmOptionsPromise(path, options, false);
15531554
return lazyRimRaf()(path, options);
15541555
}

lib/internal/fs/utils.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -940,6 +940,34 @@ const getValidatedPath = hideStackFrames((fileURLOrPath, propName = 'path') => {
940940
return path;
941941
});
942942

943+
// fs.rm(), fs.rmSync() and fsPromises.rm() resolve a trailing `.` or `..` before
944+
// removing anything. Without this the two forms disagree, because rmSync()
945+
// dispatches to std::filesystem::remove_all() while rm() walks the tree in JS.
946+
// The JS walk can also invalidate its own path: it builds child paths by
947+
// concatenation, so removing a directory that an unresolved `..` still needs in
948+
// order to resolve makes every later operation fail with ENOENT, which is then
949+
// reported as success. Refs: https://github.com/nodejs/node/issues/61958
950+
//
951+
// Only a trailing dot segment is rewritten. That is where rmdir(2) rejects the
952+
// path outright and where the walk can outlive its own resolution; leaving every
953+
// other path byte for byte identical keeps this from changing unrelated
954+
// behaviour, such as the resource string the permission model reports.
955+
const normalizeRmPath = (path) => {
956+
if (typeof path === 'string') {
957+
const base = pathModule.basename(path);
958+
return base === '.' || base === '..' ? pathModule.normalize(path) : path;
959+
}
960+
// Filenames are arbitrary byte sequences on POSIX. latin1 maps every byte to a
961+
// distinct code point and back, so the bytes survive the round trip; utf8
962+
// would rewrite invalid sequences to U+FFFD and change which path is removed.
963+
const asString = Buffer.from(path).toString('latin1');
964+
const base = pathModule.basename(asString);
965+
if (base !== '.' && base !== '..') {
966+
return path;
967+
}
968+
return Buffer.from(pathModule.normalize(asString), 'latin1');
969+
};
970+
943971
const getValidatedFd = hideStackFrames((fd, propName = 'fd') => {
944972
if (ObjectIs(fd, -0)) {
945973
return 0;
@@ -1214,6 +1242,7 @@ module.exports = {
12141242
getValidatedFd,
12151243
getValidatedPath,
12161244
handleErrorFromBinding,
1245+
normalizeRmPath,
12171246
preprocessSymlinkDestination,
12181247
realpathCacheKey: Symbol('realpathCacheKey'),
12191248
getStatFsFromBinding,
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
'use strict';
2+
3+
// Regression test for https://github.com/nodejs/node/issues/61958
4+
//
5+
// fs.rmSync() and fsPromises.rm() are documented as the synchronous and
6+
// asynchronous forms of one API, but they do not share an implementation:
7+
// rmSync() dispatches to binding.rmSync() (std::filesystem::remove_all) while
8+
// rm() and fsPromises.rm() use the JS rimraf in lib/internal/fs/rimraf.js.
9+
// They disagree for paths whose trailing component is `.` or `..`: the sync
10+
// form removes the directory contents and reports success, while the async
11+
// form rejects with EINVAL and removes nothing.
12+
//
13+
// Each case asserts two things. First the invariant from the bug report: for
14+
// identical input the two forms must succeed or fail the same way and leave
15+
// the filesystem in the same state. Second, that the surviving tree is the one
16+
// POSIX path resolution implies, so that the test still fails if both forms
17+
// are wrong in the same way.
18+
19+
const common = require('../common');
20+
const tmpdir = require('../common/tmpdir');
21+
const assert = require('node:assert');
22+
const fs = require('node:fs');
23+
const fsPromises = require('node:fs/promises');
24+
const path = require('node:path');
25+
const { pathToFileURL } = require('node:url');
26+
27+
tmpdir.refresh();
28+
29+
const RM_OPTIONS = { recursive: true, force: true };
30+
31+
// Path shapes whose trailing component is `.` or `..`, plus controls that
32+
// should be unaffected. Kept as raw strings: path.join() would normalize the
33+
// dot segments away and destroy the case under test.
34+
const DOT_SEGMENT_PATHS = [
35+
'a/b/../.', // The shape reported in the issue.
36+
'a/b/..',
37+
'a/b/.././b',
38+
'a/.',
39+
'a/b/c/.',
40+
'a/b/c/../..',
41+
'a/b/c/d/../../..',
42+
'./a', // Control: leading `.` only.
43+
'a/b', // Control: no dot segments at all.
44+
];
45+
46+
// The fixture tree, in the shape listTree() reports it.
47+
const FIXTURE_ENTRIES = ['a', 'a/b', 'a/b/c', 'a/b/c/d'];
48+
49+
// What must still exist afterwards: every entry that is neither the resolved
50+
// target nor below it. Derived from POSIX path resolution rather than from what
51+
// either implementation happens to do, so that agreeing on a wrong answer still
52+
// fails the test.
53+
function expectedSurvivors(relative) {
54+
const target = path.posix.normalize(relative);
55+
return FIXTURE_ENTRIES.filter(
56+
(entry) => entry !== target && !entry.startsWith(`${target}/`));
57+
}
58+
59+
let fixtureCounter = 0;
60+
61+
// Builds <tmpdir>/rm-fixture-N/a/b/c/d and returns the fixture root.
62+
function makeFixture() {
63+
const root = tmpdir.resolve(`rm-fixture-${fixtureCounter++}`);
64+
fs.mkdirSync(path.join(root, 'a', 'b', 'c', 'd'), { recursive: true });
65+
return root;
66+
}
67+
68+
// Sorted, root-relative listing of everything under `root`, with `/` as the
69+
// separator so two runs can be compared directly on any platform.
70+
function listTree(root) {
71+
const entries = [];
72+
(function walk(dir) {
73+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
74+
const absolute = path.join(dir, entry.name);
75+
entries.push(path.relative(root, absolute).split(path.sep).join('/'));
76+
if (entry.isDirectory()) walk(absolute);
77+
}
78+
})(root);
79+
return entries.sort();
80+
}
81+
82+
// Joins by hand rather than with path.join() to preserve dot segments.
83+
function targetPath(root, relative, encoding) {
84+
const raw = `${root}/${relative}`;
85+
if (encoding === 'string') return raw;
86+
if (encoding === 'buffer') return Buffer.from(raw);
87+
if (encoding === 'url') return pathToFileURL(raw);
88+
assert.fail(`unknown encoding ${encoding}`);
89+
}
90+
91+
// Normalizes an outcome to something comparable across the two code paths.
92+
function settled(fn) {
93+
try {
94+
fn();
95+
return { outcome: 'success' };
96+
} catch (err) {
97+
return { outcome: 'failure', code: err.code };
98+
}
99+
}
100+
101+
async function settledAsync(fn) {
102+
try {
103+
await fn();
104+
return { outcome: 'success' };
105+
} catch (err) {
106+
return { outcome: 'failure', code: err.code };
107+
}
108+
}
109+
110+
// Runs the sync and async forms against separate but identical fixtures and
111+
// reports what each did.
112+
async function compareForms(relative, encoding) {
113+
const syncRoot = makeFixture();
114+
const syncResult = settled(
115+
() => fs.rmSync(targetPath(syncRoot, relative, encoding), RM_OPTIONS));
116+
const syncTree = listTree(syncRoot);
117+
118+
const asyncRoot = makeFixture();
119+
const asyncResult = await settledAsync(
120+
() => fsPromises.rm(targetPath(asyncRoot, relative, encoding), RM_OPTIONS));
121+
const asyncTree = listTree(asyncRoot);
122+
123+
return { syncResult, syncTree, asyncResult, asyncTree };
124+
}
125+
126+
async function assertFormsAgree(relative, encoding) {
127+
const label = `rm('${relative}') with a ${encoding} path`;
128+
const { syncResult, syncTree, asyncResult, asyncTree } =
129+
await compareForms(relative, encoding);
130+
131+
assert.deepStrictEqual(
132+
asyncResult, syncResult,
133+
`${label}: fsPromises.rm() and fs.rmSync() disagree. ` +
134+
`sync=${JSON.stringify(syncResult)} async=${JSON.stringify(asyncResult)}`);
135+
136+
assert.deepStrictEqual(
137+
asyncTree, syncTree,
138+
`${label}: fsPromises.rm() and fs.rmSync() left different trees behind. ` +
139+
`sync=${JSON.stringify(syncTree)} async=${JSON.stringify(asyncTree)}`);
140+
141+
const expected = expectedSurvivors(relative);
142+
143+
assert.deepStrictEqual(
144+
syncResult, { outcome: 'success' },
145+
`${label}: fs.rmSync() should remove the resolved target, but reported ` +
146+
JSON.stringify(syncResult));
147+
148+
assert.deepStrictEqual(
149+
syncTree, expected,
150+
`${label}: fs.rmSync() left the wrong tree. ` +
151+
`got=${JSON.stringify(syncTree)} want=${JSON.stringify(expected)}`);
152+
153+
assert.deepStrictEqual(
154+
asyncTree, expected,
155+
`${label}: fsPromises.rm() left the wrong tree. ` +
156+
`got=${JSON.stringify(asyncTree)} want=${JSON.stringify(expected)}`);
157+
}
158+
159+
// A path whose bytes are not valid UTF-8. Normalizing a Buffer path through a
160+
// UTF-8 round trip rewrites these bytes to U+FFFD, which would resolve to a
161+
// different path than the caller asked for -- silently, in an API that deletes.
162+
// POSIX filenames are arbitrary bytes; Windows filenames are not, so this is
163+
// skipped there.
164+
async function assertNonUtf8BufferPathsSurvive() {
165+
const oddName = Buffer.from([0xff, 0xfe]);
166+
167+
for (const form of ['sync', 'async']) {
168+
// A bare directory, not makeFixture(): this case only needs the oddly named
169+
// subtree, and anything else in the root would just be noise here.
170+
const root = tmpdir.resolve(`rm-nonutf8-${fixtureCounter++}`);
171+
fs.mkdirSync(root, { recursive: true });
172+
const oddDir = Buffer.concat([Buffer.from(`${root}/`), oddName]);
173+
fs.mkdirSync(oddDir);
174+
fs.mkdirSync(Buffer.concat([oddDir, Buffer.from('/child')]));
175+
176+
// Resolves to oddDir itself, so the whole directory should be removed.
177+
const target = Buffer.concat([oddDir, Buffer.from('/child/..')]);
178+
179+
if (form === 'sync') {
180+
fs.rmSync(target, RM_OPTIONS);
181+
} else {
182+
await fsPromises.rm(target, RM_OPTIONS);
183+
}
184+
185+
assert.strictEqual(
186+
fs.existsSync(oddDir), false,
187+
`fs.rm (${form}) left a directory with non-UTF-8 bytes in its name behind; ` +
188+
'the path was probably rewritten during normalization');
189+
assert.deepStrictEqual(
190+
listTree(root), [],
191+
`fs.rm (${form}) left something behind under the fixture root`);
192+
}
193+
}
194+
195+
(async () => {
196+
// String paths: the form reported in the issue.
197+
for (const relative of DOT_SEGMENT_PATHS) {
198+
await assertFormsAgree(relative, 'string');
199+
}
200+
201+
// Buffer paths. fs.rm() documents `string | Buffer | URL`, so the dot
202+
// segment handling must not depend on how the path was supplied. This is
203+
// the case the reviewer asked about on PR #61968 and that was never
204+
// answered.
205+
for (const relative of DOT_SEGMENT_PATHS) {
206+
await assertFormsAgree(relative, 'buffer');
207+
}
208+
209+
// URL paths. The WHATWG URL parser resolves dot segments itself, so both
210+
// forms should receive an already-normalized path here; this pins that
211+
// assumption so a future change to path handling cannot silently break it.
212+
for (const relative of DOT_SEGMENT_PATHS) {
213+
await assertFormsAgree(relative, 'url');
214+
}
215+
216+
if (!common.isWindows) {
217+
await assertNonUtf8BufferPathsSurvive();
218+
}
219+
})().then(common.mustCall());

0 commit comments

Comments
 (0)