|
| 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