Skip to content

Commit 2729fd4

Browse files
committed
fix(rm/polyfill): handle ENOENT in rmdir more appropriately
an ENOENT from rmdir should follow the same rules as every other error handler, with one major exception. in windows, if the path passed to rmdir is not a directory an ENOENT is thrown instead of the ENOTDIR present in other platforms. to determine if we should ignore the ENOENT or throw our own ENOTDIR, we use an lstat to see if the path still exists
1 parent e6fd057 commit 2729fd4

File tree

2 files changed

+173
-4
lines changed

2 files changed

+173
-4
lines changed

lib/rm/polyfill.js

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
// - remove all callback related code
1010
// - drop sync support
1111
// - change assertions back to non-internal methods (see options.js)
12-
const { EISDIR } = require('os').constants.errno
12+
// - throws ENOTDIR when rmdir gets an ENOENT for a path that exists in Windows
13+
const errnos = require('os').constants.errno
1314
const { join } = require('path')
1415
const fs = require('../fs.js')
1516

@@ -48,21 +49,37 @@ class ERR_FS_EISDIR extends Error {
4849
message: 'is a directory',
4950
path,
5051
syscall: 'rm',
51-
errno: EISDIR,
52+
errno: errnos.EISDIR,
5253
}
5354
this.name = 'SystemError'
5455
this.code = 'ERR_FS_EISDIR'
55-
this.errno = EISDIR
56-
this.message = `Path is a directory: rm returned EISDIR (is a directory) ${path}`
56+
this.errno = errnos.EISDIR
5757
this.syscall = 'rm'
5858
this.path = path
59+
this.message = `Path is a directory: ${this.syscall} returned ${this.info.code} (is a directory) ${path}`
5960
}
6061

6162
toString () {
6263
return `${this.name} [${this.code}]: ${this.message}`
6364
}
6465
}
6566

67+
class ENOTDIR extends Error {
68+
constructor (path) {
69+
super()
70+
this.name = 'Error'
71+
this.code = 'ENOTDIR'
72+
this.errno = errnos.ENOTDIR
73+
this.syscall = 'rmdir'
74+
this.path = path
75+
this.message = `not a directory, ${this.syscall} '${this.path}'`
76+
}
77+
78+
toString () {
79+
return `${this.name}: ${this.code}: ${this.message}`
80+
}
81+
}
82+
6683
// force is passed separately here because we respect it for the first entry
6784
// into rimraf only, any further calls that are spawned as a result (i.e. to
6885
// delete content within the target) will ignore ENOENT errors
@@ -158,9 +175,26 @@ const rmdir = async (path, options, originalErr, isTop) => {
158175
if (!options.recursive && isTop) {
159176
throw originalErr || new ERR_FS_EISDIR(path)
160177
}
178+
const force = isTop ? options.force : true
161179

162180
return fs.rmdir(path)
163181
.catch(async (err) => {
182+
// in Windows, calling rmdir on a file path will fail with ENOENT rather
183+
// than ENOTDIR. to determine if that's what happened, we have to do
184+
// another lstat on the path. if the path isn't actually gone, we throw
185+
// away the ENOENT and replace it with our own ENOTDIR
186+
if (isWindows && err.code === 'ENOENT') {
187+
const stillExists = await fs.lstat(path).then(() => true, () => false)
188+
if (stillExists) {
189+
err = new ENOTDIR(path)
190+
}
191+
}
192+
193+
// not there, not a problem
194+
if (err.code === 'ENOENT' && force) {
195+
return
196+
}
197+
164198
// we may not have originalErr if lstat tells us our target is a
165199
// directory but that changes before we actually remove it, so
166200
// only throw it here if it's set

test/rm/polyfill.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class ErrorCode extends Error {
1818
const EISDIR = new ErrorCode('EISDIR')
1919
const EMFILE = new ErrorCode('EMFILE')
2020
const ENOENT = new ErrorCode('ENOENT')
21+
const ENOTDIR = new ErrorCode('ENOTDIR')
2122
const EPERM = new ErrorCode('EPERM')
2223
const EUNKNOWN = new ErrorCode('EUNKNOWN') // fake error code for else coverage
2324

@@ -86,6 +87,23 @@ t.test('can delete a directory', async (t) => {
8687
t.equal(await fs.exists(target), false, 'target no longer exists')
8788
})
8889

90+
t.test('resolves when rmdir gets ENOENT with force', async (t) => {
91+
const dir = t.testdir()
92+
// doesn't actually exist
93+
const target = join(dir, 'directory')
94+
95+
const lstat = realFs.lstat
96+
realFs.lstat = (path, cb) => {
97+
realFs.lstat = lstat
98+
setImmediate(cb, null, { isDirectory: () => true })
99+
}
100+
t.teardown(() => {
101+
realFs.lstat = lstat
102+
})
103+
104+
await t.resolves(rm(target, { recursive: true, force: true }))
105+
})
106+
89107
t.test('rejects with EISDIR when deleting a directory without recursive', async (t) => {
90108
const dir = t.testdir({
91109
directory: {},
@@ -198,6 +216,43 @@ t.test('rejects with unknown error removing top directory', async (t) => {
198216
})
199217
})
200218

219+
t.test('posix', async (t) => {
220+
let posixRm
221+
t.before(() => {
222+
t.context.platform = Object.getOwnPropertyDescriptor(process, 'platform')
223+
Object.defineProperty(process, 'platform', {
224+
...t.context.platform,
225+
value: 'linux',
226+
})
227+
posixRm = t.mock('../../lib/rm/polyfill.js')
228+
})
229+
230+
t.teardown(() => {
231+
Object.defineProperty(process, 'platform', t.context.platform)
232+
})
233+
234+
t.test('EPERM in unlink calls rmdir', async (t) => {
235+
const dir = t.testdir({
236+
file: 'a file',
237+
})
238+
const target = join(dir, 'file')
239+
240+
const rmdir = realFs.rmdir
241+
const unlink = realFs.unlink
242+
// need to mock rmdir too so we can force an ENOTDIR
243+
realFs.rmdir = (path, cb) => setImmediate(cb, ENOTDIR)
244+
realFs.unlink = (path, cb) => setImmediate(cb, EPERM)
245+
t.teardown(() => {
246+
realFs.rmdir = rmdir
247+
realFs.unlink = unlink
248+
})
249+
250+
await t.rejects(posixRm(target, { recursive: true }), {
251+
code: 'EPERM',
252+
}, 'got the EPERM')
253+
})
254+
})
255+
201256
t.test('windows', async (t) => {
202257
// t.mock instead of require so we flush the cache first
203258
let winRm
@@ -454,4 +509,84 @@ t.test('windows', async (t) => {
454509
t.not(await fs.exists(join(target, 'file')), 'file no longer exists')
455510
t.not(await fs.exists(target), 'target no longer exists')
456511
})
512+
513+
t.test('ENOENT in rmdir resolves when file is really gone with force', async (t) => {
514+
const dir = t.testdir({
515+
directory: {},
516+
})
517+
const target = join(dir, 'directory')
518+
519+
let rmdirCalled = false
520+
const lstat = realFs.lstat
521+
const rmdir = realFs.rmdir
522+
realFs.rmdir = (path, cb) => {
523+
rmdirCalled = true
524+
realFs.lstat = (path, cb) => setImmediate(cb, ENOENT)
525+
setImmediate(cb, ENOENT)
526+
}
527+
t.teardown(() => {
528+
realFs.lstat = lstat
529+
realFs.rmdir = rmdir
530+
})
531+
532+
await winRm(target, { recursive: true, force: true })
533+
t.ok(rmdirCalled, 'rmdir() was called')
534+
})
535+
536+
t.test('ENOENT in rmdir rejects with ENOENT when file is really gone without force', async (t) => {
537+
const dir = t.testdir({
538+
directory: {},
539+
})
540+
const target = join(dir, 'directory')
541+
542+
const lstat = realFs.lstat
543+
const rmdir = realFs.rmdir
544+
realFs.rmdir = (path, cb) => {
545+
// need to hijack this here so only the lstat after rmdir gets the ENOENT
546+
realFs.lstat = (path, cb) => setImmediate(cb, ENOENT)
547+
setImmediate(cb, ENOENT)
548+
}
549+
t.teardown(() => {
550+
realFs.lstat = lstat
551+
realFs.rmdir = rmdir
552+
})
553+
554+
await t.rejects(winRm(target, { recursive: true }), {
555+
code: 'ENOENT',
556+
}, 'got the ENOENT')
557+
})
558+
559+
t.test('ENOENT in rmdir rejects with ENOTDIR when target still exists', async (t) => {
560+
const dir = t.testdir({
561+
directory: {},
562+
})
563+
const target = join(dir, 'directory')
564+
565+
const rmdir = realFs.rmdir
566+
realFs.rmdir = (path, cb) => setImmediate(cb, ENOENT)
567+
t.teardown(() => {
568+
realFs.rmdir = rmdir
569+
})
570+
571+
await t.rejects(winRm(target, { recursive: true }), {
572+
code: 'ENOTDIR',
573+
}, 'got the ENOTDIR')
574+
})
575+
576+
t.test('ENOENT in rmdir rejects with ENOTDIR when target still exists and force is set', async (t) => {
577+
const dir = t.testdir({
578+
directory: {},
579+
})
580+
const target = join(dir, 'directory')
581+
582+
const rmdir = realFs.rmdir
583+
realFs.rmdir = (path, cb) => setImmediate(cb, ENOENT)
584+
t.teardown(() => {
585+
realFs.rmdir = rmdir
586+
})
587+
588+
await t.rejects(winRm(target, { recursive: true, force: true }), {
589+
code: 'ENOTDIR',
590+
}, 'got the ENOTDIR')
591+
})
457592
})

0 commit comments

Comments
 (0)