Skip to content

Commit 5f57697

Browse files
committed
mimic node's intended behavior with weird paths
When removing any path that has `..` or `.` as path portions, call `path.normalize` on the path before attempting to process it. When removing the path `''`, throw a `stat ENOENT` error. When removing `.`, replace with `process.cwd()` and proceed as normal. Also, add support for deleting `file:` URLs and Buffer paths, which are normalized to `string` for the benefit of older Node versions. Glob patterns must still be normal `string` values. This mirrors the behavior of node, once nodejs/node#61968 lands. Closes: #342 Fixes: #326 Re: nodejs/node#61958 Credit: @abhu85, @RajeshKumar11, @isaacs
1 parent a51c7b0 commit 5f57697

10 files changed

Lines changed: 216 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
# 6.2
2+
3+
- Handle `''`, `'..'` and `'.'` paths the same as Node's intended
4+
behavior.
5+
- Add support for `file:` URL objects and Buffers. (Glob patterns
6+
still have to be normal `string` types.)
7+
18
# 6.1
29

310
- Move to native `fs/promises` usage instead of promisifying

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ something was omitted from the removal via a `filter` option.
8282
This first parameter is a path or array of paths. The second
8383
argument is an options object.
8484

85+
If interpreted as `glob` patterns, then the paths must be
86+
normal `string` values. If not glob pattern matching, then you
87+
may also pass in `file:` URL objects, or `Buffer` objects, which
88+
will be turned into string paths in the normal ways.
89+
8590
Options:
8691

8792
- `preserveRoot`: If set to boolean `false`, then allow the

src/index.ts

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import { glob, globSync } from 'glob'
2-
import {
3-
optArg,
4-
optArgSync,
5-
RimrafAsyncOptions,
6-
RimrafSyncOptions,
7-
} from './opt-arg.js'
8-
import pathArg from './path-arg.js'
2+
import { optArg, optArgSync } from './opt-arg.js'
3+
import type { RimrafAsyncOptions, RimrafSyncOptions } from './opt-arg.js'
4+
import { pathArg } from './path-arg.js'
5+
import type { PathLike } from './path-arg.js'
96
import { rimrafManual, rimrafManualSync } from './rimraf-manual.js'
107
import {
118
rimrafMoveRemove,
@@ -15,23 +12,27 @@ import { rimrafNative, rimrafNativeSync } from './rimraf-native.js'
1512
import { rimrafPosix, rimrafPosixSync } from './rimraf-posix.js'
1613
import { rimrafWindows, rimrafWindowsSync } from './rimraf-windows.js'
1714
import { useNative, useNativeSync } from './use-native.js'
15+
import { isStrings } from './is-strings.js'
1816

19-
export {
20-
assertRimrafOptions,
21-
isRimrafOptions,
22-
type RimrafAsyncOptions,
23-
type RimrafOptions,
24-
type RimrafSyncOptions,
17+
export type { PathLike } from './path-arg.js'
18+
19+
export type {
20+
RimrafAsyncOptions,
21+
RimrafOptions,
22+
RimrafSyncOptions,
2523
} from './opt-arg.js'
2624

27-
const wrap =
28-
(fn: (p: string, o: RimrafAsyncOptions) => Promise<boolean>) =>
29-
async (
25+
export { assertRimrafOptions, isRimrafOptions } from './opt-arg.js'
26+
27+
const wrap = (
28+
fn: (p: string, o: RimrafAsyncOptions) => Promise<boolean>,
29+
) => {
30+
const rimraf = async (
3031
path: string | string[],
3132
opt?: RimrafAsyncOptions,
3233
): Promise<boolean> => {
3334
const options = optArg(opt)
34-
if (options.glob) {
35+
if (options.glob && isStrings(path)) {
3536
path = await glob(path, options.glob)
3637
}
3738
if (Array.isArray(path)) {
@@ -42,12 +43,16 @@ const wrap =
4243
return !!(await fn(pathArg(path, options), options))
4344
}
4445
}
46+
return rimraf
47+
}
4548

46-
const wrapSync =
47-
(fn: (p: string, o: RimrafSyncOptions) => boolean) =>
48-
(path: string | string[], opt?: RimrafSyncOptions): boolean => {
49+
const wrapSync = (fn: (p: string, o: RimrafSyncOptions) => boolean) => {
50+
const rimraf = (
51+
path: PathLike | PathLike[],
52+
opt?: RimrafSyncOptions,
53+
): boolean => {
4954
const options = optArgSync(opt)
50-
if (options.glob) {
55+
if (options.glob && isStrings(path)) {
5156
path = globSync(path, options.glob)
5257
}
5358
if (Array.isArray(path)) {
@@ -58,6 +63,8 @@ const wrapSync =
5863
return !!fn(pathArg(path, options), options)
5964
}
6065
}
66+
return rimraf
67+
}
6168

6269
export const nativeSync = wrapSync(rimrafNativeSync)
6370
export const native = Object.assign(wrap(rimrafNative), {

src/is-strings.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { PathLike } from './path-arg.js'
2+
3+
export const isStrings = (
4+
p: PathLike | PathLike[],
5+
): p is string | string[] => {
6+
if (typeof p === 'string') return true
7+
if (!Array.isArray(p)) return false
8+
for (const s of p) {
9+
if (typeof s !== 'string') {
10+
return false
11+
}
12+
}
13+
return true
14+
}

src/path-arg.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,43 @@
1-
import { parse, resolve } from 'path'
1+
import { parse, resolve, normalize } from 'path'
22
import { inspect } from 'util'
33
import { RimrafAsyncOptions } from './index.js'
4+
import { fileURLToPath } from 'url'
45

5-
const pathArg = (path: string, opt: RimrafAsyncOptions = {}) => {
6-
const type = typeof path
7-
if (type !== 'string') {
6+
const dotPattern = /(?:^|\\|\/)\.\.?(?:$|\\|\/)/
7+
const BufferToString = (b: ArrayBufferView) =>
8+
Buffer.prototype.toString.call(b, 'utf8')
9+
10+
export type PathLike = string | URL | ArrayBufferLike | Buffer
11+
12+
export function pathArg(
13+
path: PathLike,
14+
opt: RimrafAsyncOptions = {},
15+
): string {
16+
if (ArrayBuffer.isView(path)) {
17+
path = BufferToString(path)
18+
} else if (path instanceof URL && path.protocol === 'file:') {
19+
path = fileURLToPath(path)
20+
}
21+
if (typeof path !== 'string') {
22+
const type = typeof path
823
const ctor = path && type === 'object' && path.constructor
924
const received =
10-
ctor && ctor.name ? `an instance of ${ctor.name}`
25+
path instanceof URL ? `"${path.protocol}" URL object`
26+
: ctor && ctor.name ? `an instance of ${ctor.name}`
1127
: type === 'object' ? inspect(path)
1228
: `type ${type} ${path}`
1329
const msg =
14-
'The "path" argument must be of type string. ' +
30+
'The "path" argument must be of type string, Buffer, or "file:" URL. ' +
1531
`Received ${received}`
1632
throw Object.assign(new TypeError(msg), {
1733
path,
1834
code: 'ERR_INVALID_ARG_TYPE',
1935
})
2036
}
37+
if (dotPattern.test(path)) {
38+
path = normalize(path)
39+
}
40+
if (path === '.') path = process.cwd()
2141

2242
if (/\0/.test(path)) {
2343
// simulate same failure that node raises
@@ -28,10 +48,22 @@ const pathArg = (path: string, opt: RimrafAsyncOptions = {}) => {
2848
})
2949
}
3050

31-
path = resolve(path)
32-
const { root } = parse(path)
51+
if (path === '') {
52+
throw Object.assign(
53+
new Error("'ENOENT: no such file or directory, lstat ''"),
54+
{
55+
errno: -2,
56+
code: 'ENOENT',
57+
syscall: 'lstat',
58+
path: '',
59+
},
60+
)
61+
}
62+
63+
const rpath = resolve(path)
64+
const { root } = parse(rpath)
3365

34-
if (path === root && opt.preserveRoot !== false) {
66+
if (rpath === root && opt.preserveRoot !== false) {
3567
const msg =
3668
'refusing to remove root directory without preserveRoot:false'
3769
throw Object.assign(new Error(msg), {
@@ -42,8 +74,7 @@ const pathArg = (path: string, opt: RimrafAsyncOptions = {}) => {
4274

4375
if (process.platform === 'win32') {
4476
const badWinChars = /[*|"<>?:]/
45-
const { root } = parse(path)
46-
if (badWinChars.test(path.substring(root.length))) {
77+
if (badWinChars.test(rpath.substring(root.length))) {
4778
throw Object.assign(new Error('Illegal characters in path.'), {
4879
path,
4980
code: 'EINVAL',
@@ -53,5 +84,3 @@ const pathArg = (path: string, opt: RimrafAsyncOptions = {}) => {
5384

5485
return path
5586
}
56-
57-
export default pathArg

src/rimraf-native.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { RimrafAsyncOptions, RimrafSyncOptions } from './index.js'
22
import { promises, rmSync } from './fs.js'
33
const { rm } = promises
44

5+
// NB: node will raise the "no rm cwd" error for us
6+
57
export const rimrafNative = async (
68
path: string,
79
opt: RimrafAsyncOptions,

test/index.ts

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { statSync } from 'fs'
2-
import { resolve } from 'path'
32
import t from 'tap'
43
import {
54
rimraf,
@@ -26,9 +25,11 @@ t.test('mocky unit tests to select the correct function', async t => {
2625
return USE_NATIVE
2726
},
2827
},
29-
'../dist/esm/path-arg.js': (path: string) => {
30-
CALLS.push(['pathArg', path])
31-
return path
28+
'../dist/esm/path-arg.js': {
29+
pathArg: (path: string) => {
30+
CALLS.push(['pathArg', path])
31+
return path
32+
},
3233
},
3334
'../dist/esm/opt-arg.js': {
3435
...OPTARG,
@@ -181,12 +182,12 @@ t.test('accept array of paths as first arg', async t => {
181182
true,
182183
)
183184
t.same(ASYNC_CALLS, [
184-
[resolve('a'), {}],
185-
[resolve('b'), {}],
186-
[resolve('c'), {}],
187-
[resolve('i'), { x: 'ya' }],
188-
[resolve('j'), { x: 'ya' }],
189-
[resolve('k'), { x: 'ya' }],
185+
['a', {}],
186+
['b', {}],
187+
['c', {}],
188+
['i', { x: 'ya' }],
189+
['j', { x: 'ya' }],
190+
['k', { x: 'ya' }],
190191
])
191192

192193
t.equal(rimrafSync(['x', 'y', 'z']), true)
@@ -197,12 +198,12 @@ t.test('accept array of paths as first arg', async t => {
197198
true,
198199
)
199200
t.same(SYNC_CALLS, [
200-
[resolve('x'), {}],
201-
[resolve('y'), {}],
202-
[resolve('z'), {}],
203-
[resolve('m'), { cat: 'chai' }],
204-
[resolve('n'), { cat: 'chai' }],
205-
[resolve('o'), { cat: 'chai' }],
201+
['x', {}],
202+
['y', {}],
203+
['z', {}],
204+
['m', { cat: 'chai' }],
205+
['n', { cat: 'chai' }],
206+
['o', { cat: 'chai' }],
206207
])
207208
})
208209

test/is-strings.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import t from 'tap'
2+
3+
import { isStrings } from '../src/is-strings.js'
4+
5+
t.equal(isStrings('asdf'), true)
6+
t.equal(isStrings('asdf'.split('')), true)
7+
t.equal(isStrings([]), true)
8+
//@ts-expect-error
9+
t.equal(isStrings([{x:1}]), false)
10+
//@ts-expect-error
11+
t.equal(isStrings({x:1}), false)

test/path-arg.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
import * as PATH from 'path'
22
import t from 'tap'
3+
import { pathToFileURL } from 'url'
34
import { inspect } from 'util'
45

56
for (const platform of ['win32', 'posix'] as const) {
67
t.test(platform, async t => {
78
t.intercept(process, 'platform', { value: platform })
89
const path = PATH[platform] || PATH
9-
const { default: pathArg } = (await t.mockImport(
10-
'../src/path-arg.js',
11-
{
12-
path,
13-
},
14-
)) as typeof import('../src/path-arg.js')
10+
const sep = path.sep
11+
const { pathArg } = (await t.mockImport('../src/path-arg.js', {
12+
path,
13+
})) as typeof import('../src/path-arg.js')
1514

16-
t.equal(pathArg('a/b/c'), path.resolve('a/b/c'))
15+
t.equal(pathArg('a/b/c'), 'a/b/c')
1716
t.throws(
1817
() => pathArg('a\0b'),
1918
Error('path must be a string without null bytes'),
@@ -42,23 +41,41 @@ for (const platform of ['win32', 'posix'] as const) {
4241
t.throws(() => pathArg('/', { preserveRoot: undefined }), {
4342
code: 'ERR_PRESERVE_ROOT',
4443
})
45-
t.equal(pathArg('/', { preserveRoot: false }), path.resolve('/'))
44+
t.equal(pathArg('/', { preserveRoot: false }), '/')
4645

4746
//@ts-expect-error
4847
t.throws(() => pathArg({}), {
4948
code: 'ERR_INVALID_ARG_TYPE',
5049
path: {},
5150
message:
52-
'The "path" argument must be of type string. ' +
51+
'The "path" argument must be of type string, Buffer, or "file:" URL. ' +
5352
'Received an instance of Object',
5453
name: 'TypeError',
5554
})
55+
t.equal(pathArg(pathToFileURL(process.cwd())), process.cwd())
56+
t.equal(pathArg('.'), process.cwd())
57+
t.equal(pathArg(Buffer.from('a/b/c/../.')), `a${sep}b`)
58+
t.throws(() => pathArg(''), {
59+
message: "'ENOENT: no such file or directory, lstat ''",
60+
errno: -2,
61+
code: 'ENOENT',
62+
syscall: 'lstat',
63+
path: '',
64+
})
65+
t.throws(() => pathArg(new URL('https://example.com/')), {
66+
code: 'ERR_INVALID_ARG_TYPE',
67+
path: {},
68+
message:
69+
'The "path" argument must be of type string, Buffer, or "file:" URL. ' +
70+
`Received "https:" URL`,
71+
name: 'TypeError',
72+
})
5673
//@ts-expect-error
5774
t.throws(() => pathArg([]), {
5875
code: 'ERR_INVALID_ARG_TYPE',
5976
path: [],
6077
message:
61-
'The "path" argument must be of type string. ' +
78+
'The "path" argument must be of type string, Buffer, or "file:" URL. ' +
6279
'Received an instance of Array',
6380
name: 'TypeError',
6481
})
@@ -67,7 +84,7 @@ for (const platform of ['win32', 'posix'] as const) {
6784
code: 'ERR_INVALID_ARG_TYPE',
6885
path: Object.create(null) as object,
6986
message:
70-
'The "path" argument must be of type string. ' +
87+
'The "path" argument must be of type string, Buffer, or "file:" URL. ' +
7188
`Received ${inspect(Object.create(null))}`,
7289
name: 'TypeError',
7390
})
@@ -76,7 +93,7 @@ for (const platform of ['win32', 'posix'] as const) {
7693
code: 'ERR_INVALID_ARG_TYPE',
7794
path: true,
7895
message:
79-
'The "path" argument must be of type string. ' +
96+
'The "path" argument must be of type string, Buffer, or "file:" URL. ' +
8097
`Received type boolean true`,
8198
name: 'TypeError',
8299
})

0 commit comments

Comments
 (0)