From ed2293e3d70b36d139023dbe8f31faab3f255dd1 Mon Sep 17 00:00:00 2001 From: cjihrig Date: Fri, 16 Aug 2019 13:17:21 -0400 Subject: [PATCH] fs: add recursive option to rmdir() This commit adds a recursive option to fs.rmdir(), fs.rmdirSync(), and fs.promises.rmdir(). The implementation is a port of the npm module rimraf. PR-URL: https://github.com/nodejs/node/pull/29168 Reviewed-By: James M Snell Reviewed-By: Roman Reiss Reviewed-By: Ben Coe Reviewed-By: Rich Trott Reviewed-By: Jiawen Geng --- LICENSE | 19 ++ doc/api/fs.md | 55 ++++- lib/fs.js | 36 +++- lib/internal/fs/promises.js | 14 +- lib/internal/fs/rimraf.js | 252 +++++++++++++++++++++++ lib/internal/fs/utils.js | 29 +++ node.gyp | 1 + test/parallel/test-fs-rmdir-recursive.js | 152 ++++++++++++++ tools/license-builder.sh | 3 + 9 files changed, 550 insertions(+), 11 deletions(-) create mode 100644 lib/internal/fs/rimraf.js create mode 100644 test/parallel/test-fs-rmdir-recursive.js diff --git a/LICENSE b/LICENSE index 1f2435a7225834..f57c3dc0c37194 100644 --- a/LICENSE +++ b/LICENSE @@ -1507,3 +1507,22 @@ The externally maintained libraries used by Node.js are: ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. """ + +- rimraf, located at lib/internal/fs/rimraf.js, is licensed as follows: + """ + The ISC License + + Copyright (c) Isaac Z. Schlueter and Contributors + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR + IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + """ diff --git a/doc/api/fs.md b/doc/api/fs.md index 5e2923a0ff647e..7e8266a90ec63a 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -3017,10 +3017,14 @@ changes: Synchronous rename(2). Returns `undefined`. -## fs.rmdir(path, callback) +## fs.rmdir(path[, options], callback) +> Stability: 1 - Recursive removal is experimental. + * `path` {string|Buffer|URL} +* `options` {Object} + * `emfileWait` {integer} If an `EMFILE` error is encountered, Node.js will + retry the operation with a linear backoff of 1ms longer on each try until the + timeout duration passes this limit. This option is ignored if the `recursive` + option is not `true`. **Default:** `1000`. + * `maxBusyTries` {integer} If an `EBUSY`, `ENOTEMPTY`, or `EPERM` error is + encountered, Node.js will retry the operation with a linear backoff wait of + 100ms longer on each try. This option represents the number of retries. This + option is ignored if the `recursive` option is not `true`. **Default:** `3`. + * `recursive` {boolean} If `true`, perform a recursive directory removal. In + recursive mode, errors are not reported if `path` does not exist, and + operations are retried on failure. **Default:** `false`. * `callback` {Function} * `err` {Error} @@ -3045,17 +3063,27 @@ to the completion callback. Using `fs.rmdir()` on a file (not a directory) results in an `ENOENT` error on Windows and an `ENOTDIR` error on POSIX. -## fs.rmdirSync(path) +## fs.rmdirSync(path[, options]) +> Stability: 1 - Recursive removal is experimental. + * `path` {string|Buffer|URL} +* `options` {Object} + * `recursive` {boolean} If `true`, perform a recursive directory removal. In + recursive mode, errors are not reported if `path` does not exist, and + operations are retried on failure. **Default:** `false`. Synchronous rmdir(2). Returns `undefined`. @@ -4694,12 +4722,31 @@ added: v10.0.0 Renames `oldPath` to `newPath` and resolves the `Promise` with no arguments upon success. -### fsPromises.rmdir(path) +### fsPromises.rmdir(path[, options]) +> Stability: 1 - Recursive removal is experimental. + * `path` {string|Buffer|URL} +* `options` {Object} + * `emfileWait` {integer} If an `EMFILE` error is encountered, Node.js will + retry the operation with a linear backoff of 1ms longer on each try until the + timeout duration passes this limit. This option is ignored if the `recursive` + option is not `true`. **Default:** `1000`. + * `maxBusyTries` {integer} If an `EBUSY`, `ENOTEMPTY`, or `EPERM` error is + encountered, Node.js will retry the operation with a linear backoff wait of + 100ms longer on each try. This option represents the number of retries. This + option is ignored if the `recursive` option is not `true`. **Default:** `3`. + * `recursive` {boolean} If `true`, perform a recursive directory removal. In + recursive mode, errors are not reported if `path` does not exist, and + operations are retried on failure. **Default:** `false`. * Returns: {Promise} Removes the directory identified by `path` then resolves the `Promise` with @@ -5193,7 +5240,7 @@ the file contents. [`fs.readdir()`]: #fs_fs_readdir_path_options_callback [`fs.readdirSync()`]: #fs_fs_readdirsync_path_options [`fs.realpath()`]: #fs_fs_realpath_path_options_callback -[`fs.rmdir()`]: #fs_fs_rmdir_path_callback +[`fs.rmdir()`]: #fs_fs_rmdir_path_options_callback [`fs.stat()`]: #fs_fs_stat_path_options_callback [`fs.symlink()`]: #fs_fs_symlink_target_path_type_callback [`fs.utimes()`]: #fs_fs_utimes_path_atime_mtime_callback diff --git a/lib/fs.js b/lib/fs.js index cf25a9a2f781c6..89eadf067cd2d3 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -76,6 +76,7 @@ const { validateOffsetLengthRead, validateOffsetLengthWrite, validatePath, + validateRmdirOptions, warnOnNonPortableTemplate } = require('internal/fs/utils'); const { @@ -100,6 +101,8 @@ let watchers; let ReadFileContext; let ReadStream; let WriteStream; +let rimraf; +let rimrafSync; // These have to be separate because of how graceful-fs happens to do it's // monkeypatching. @@ -720,16 +723,41 @@ function ftruncateSync(fd, len = 0) { handleErrorFromBinding(ctx); } -function rmdir(path, callback) { + +function lazyLoadRimraf() { + if (rimraf === undefined) + ({ rimraf, rimrafSync } = require('internal/fs/rimraf')); +} + +function rmdir(path, options, callback) { + if (typeof options === 'function') { + callback = options; + options = undefined; + } + callback = makeCallback(callback); - path = getValidatedPath(path); + path = pathModule.toNamespacedPath(getValidatedPath(path)); + options = validateRmdirOptions(options); + + if (options.recursive) { + lazyLoadRimraf(); + return rimraf(path, options, callback); + } + const req = new FSReqCallback(); req.oncomplete = callback; - binding.rmdir(pathModule.toNamespacedPath(path), req); + binding.rmdir(path, req); } -function rmdirSync(path) { +function rmdirSync(path, options) { path = getValidatedPath(path); + options = validateRmdirOptions(options); + + if (options.recursive) { + lazyLoadRimraf(); + return rimrafSync(pathModule.toNamespacedPath(path), options); + } + const ctx = { path }; binding.rmdir(pathModule.toNamespacedPath(path), undefined, ctx); handleErrorFromBinding(ctx); diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index 171fec6e65a99c..a642641acf59a5 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -18,6 +18,7 @@ const { ERR_METHOD_NOT_IMPLEMENTED } = require('internal/errors').codes; const { isUint8Array } = require('internal/util/types'); +const { rimrafPromises } = require('internal/fs/rimraf'); const { copyObject, getDirents, @@ -32,6 +33,7 @@ const { validateBufferArray, validateOffsetLengthRead, validateOffsetLengthWrite, + validateRmdirOptions, warnOnNonPortableTemplate } = require('internal/fs/utils'); const { @@ -291,9 +293,15 @@ async function ftruncate(handle, len = 0) { return binding.ftruncate(handle.fd, len, kUsePromises); } -async function rmdir(path) { - path = getValidatedPath(path); - return binding.rmdir(pathModule.toNamespacedPath(path), kUsePromises); +async function rmdir(path, options) { + path = pathModule.toNamespacedPath(getValidatedPath(path)); + options = validateRmdirOptions(options); + + if (options.recursive) { + return rimrafPromises(path, options); + } + + return binding.rmdir(path, kUsePromises); } async function fdatasync(handle) { diff --git a/lib/internal/fs/rimraf.js b/lib/internal/fs/rimraf.js new file mode 100644 index 00000000000000..73f783d1d231f2 --- /dev/null +++ b/lib/internal/fs/rimraf.js @@ -0,0 +1,252 @@ +// This file is a modified version of the rimraf module on npm. It has been +// modified in the following ways: +// - Use of the assert module has been replaced with core's error system. +// - All code related to the glob dependency has been removed. +// - Bring your own custom fs module is not currently supported. +// - Some basic code cleanup. +'use strict'; +const { + chmod, + chmodSync, + lstat, + lstatSync, + readdir, + readdirSync, + rmdir, + rmdirSync, + stat, + statSync, + unlink, + unlinkSync +} = require('fs'); +const { join } = require('path'); +const { setTimeout } = require('timers'); +const notEmptyErrorCodes = new Set(['ENOTEMPTY', 'EEXIST', 'EPERM']); +const isWindows = process.platform === 'win32'; +const epermHandler = isWindows ? fixWinEPERM : _rmdir; +const epermHandlerSync = isWindows ? fixWinEPERMSync : _rmdirSync; +const numRetries = isWindows ? 100 : 1; + + +function rimraf(path, options, callback) { + let timeout = 0; // For EMFILE handling. + let busyTries = 0; + + _rimraf(path, options, function CB(err) { + if (err) { + if ((err.code === 'EBUSY' || err.code === 'ENOTEMPTY' || + err.code === 'EPERM') && busyTries < options.maxBusyTries) { + busyTries++; + return setTimeout(_rimraf, busyTries * 100, path, options, CB); + } + + if (err.code === 'EMFILE' && timeout < options.emfileWait) + return setTimeout(_rimraf, timeout++, path, options, CB); + + // The file is already gone. + if (err.code === 'ENOENT') + err = null; + } + + callback(err); + }); +} + + +function _rimraf(path, options, callback) { + // SunOS lets the root user unlink directories. Use lstat here to make sure + // it's not a directory. + lstat(path, (err, stats) => { + if (err) { + if (err.code === 'ENOENT') + return callback(null); + + // Windows can EPERM on stat. + if (isWindows && err.code === 'EPERM') + return fixWinEPERM(path, options, err, callback); + } else if (stats.isDirectory()) { + return _rmdir(path, options, err, callback); + } + + unlink(path, (err) => { + if (err) { + if (err.code === 'ENOENT') + return callback(null); + if (err.code === 'EISDIR') + return _rmdir(path, options, err, callback); + if (err.code === 'EPERM') { + return epermHandler(path, options, err, callback); + } + } + + return callback(err); + }); + }); +} + + +function fixWinEPERM(path, options, originalErr, callback) { + chmod(path, 0o666, (err) => { + if (err) + return callback(err.code === 'ENOENT' ? null : originalErr); + + stat(path, (err, stats) => { + if (err) + return callback(err.code === 'ENOENT' ? null : originalErr); + + if (stats.isDirectory()) + _rmdir(path, options, originalErr, callback); + else + unlink(path, callback); + }); + }); +} + + +function _rmdir(path, options, originalErr, callback) { + rmdir(path, (err) => { + if (err) { + if (notEmptyErrorCodes.has(err.code)) + return _rmchildren(path, options, callback); + if (err.code === 'ENOTDIR') + return callback(originalErr); + } + + callback(err); + }); +} + + +function _rmchildren(path, options, callback) { + readdir(path, (err, files) => { + if (err) + return callback(err); + + let numFiles = files.length; + + if (numFiles === 0) + return rmdir(path, callback); + + let done = false; + + files.forEach((child) => { + rimraf(join(path, child), options, (err) => { + if (done) + return; + + if (err) { + done = true; + return callback(err); + } + + numFiles--; + if (numFiles === 0) + rmdir(path, callback); + }); + }); + }); +} + + +function rimrafPromises(path, options) { + return new Promise((resolve, reject) => { + rimraf(path, options, (err) => { + if (err) + return reject(err); + + resolve(); + }); + }); +} + + +function rimrafSync(path, options) { + let stats; + + try { + stats = lstatSync(path); + } catch (err) { + if (err.code === 'ENOENT') + return; + + // Windows can EPERM on stat. + if (isWindows && err.code === 'EPERM') + fixWinEPERMSync(path, options, err); + } + + try { + // SunOS lets the root user unlink directories. + if (stats !== undefined && stats.isDirectory()) + _rmdirSync(path, options, null); + else + unlinkSync(path); + } catch (err) { + if (err.code === 'ENOENT') + return; + if (err.code === 'EPERM') + return epermHandlerSync(path, options, err); + if (err.code !== 'EISDIR') + throw err; + + _rmdirSync(path, options, err); + } +} + + +function _rmdirSync(path, options, originalErr) { + try { + rmdirSync(path); + } catch (err) { + if (err.code === 'ENOENT') + return; + if (err.code === 'ENOTDIR') + throw originalErr; + + if (notEmptyErrorCodes.has(err.code)) { + // Removing failed. Try removing all children and then retrying the + // original removal. Windows has a habit of not closing handles promptly + // when files are deleted, resulting in spurious ENOTEMPTY failures. Work + // around that issue by retrying on Windows. + readdirSync(path).forEach((child) => { + rimrafSync(join(path, child), options); + }); + + for (let i = 0; i < numRetries; i++) { + try { + return rmdirSync(path, options); + } catch {} // Ignore errors. + } + } + } +} + + +function fixWinEPERMSync(path, options, originalErr) { + try { + chmodSync(path, 0o666); + } catch (err) { + if (err.code === 'ENOENT') + return; + + throw originalErr; + } + + let stats; + + try { + stats = statSync(path); + } catch (err) { + if (err.code === 'ENOENT') + return; + + throw originalErr; + } + + if (stats.isDirectory()) + _rmdirSync(path, options, originalErr); + else + unlinkSync(path); +} + + +module.exports = { rimraf, rimrafPromises, rimrafSync }; diff --git a/lib/internal/fs/utils.js b/lib/internal/fs/utils.js index ac426b55881aee..80f0784681b588 100644 --- a/lib/internal/fs/utils.js +++ b/lib/internal/fs/utils.js @@ -22,6 +22,10 @@ const { } = require('internal/util/types'); const { once } = require('internal/util'); const { toPathIfFileURL } = require('internal/url'); +const { + validateInt32, + validateUint32 +} = require('internal/validators'); const pathModule = require('path'); const kType = Symbol('type'); const kStats = Symbol('stats'); @@ -526,6 +530,30 @@ function warnOnNonPortableTemplate(template) { } } +const defaultRmdirOptions = { + emfileWait: 1000, + maxBusyTries: 3, + recursive: false, +}; + +const validateRmdirOptions = hideStackFrames((options) => { + if (options === undefined) + return defaultRmdirOptions; + if (options === null || typeof options !== 'object') + throw new ERR_INVALID_ARG_TYPE('options', 'object', options); + + options = { ...defaultRmdirOptions, ...options }; + + if (typeof options.recursive !== 'boolean') + throw new ERR_INVALID_ARG_TYPE('recursive', 'boolean', options.recursive); + + validateInt32(options.emfileWait, 'emfileWait', 0); + validateUint32(options.maxBusyTries, 'maxBusyTries'); + + return options; +}); + + module.exports = { assertEncoding, BigIntStats, // for testing @@ -546,5 +574,6 @@ module.exports = { validateOffsetLengthRead, validateOffsetLengthWrite, validatePath, + validateRmdirOptions, warnOnNonPortableTemplate }; diff --git a/node.gyp b/node.gyp index 4576e5a335c3e6..1d45f5117144b0 100644 --- a/node.gyp +++ b/node.gyp @@ -124,6 +124,7 @@ 'lib/internal/freeze_intrinsics.js', 'lib/internal/fs/promises.js', 'lib/internal/fs/read_file_context.js', + 'lib/internal/fs/rimraf.js', 'lib/internal/fs/streams.js', 'lib/internal/fs/sync_write_stream.js', 'lib/internal/fs/utils.js', diff --git a/test/parallel/test-fs-rmdir-recursive.js b/test/parallel/test-fs-rmdir-recursive.js new file mode 100644 index 00000000000000..b020221b27b0a3 --- /dev/null +++ b/test/parallel/test-fs-rmdir-recursive.js @@ -0,0 +1,152 @@ +// Flags: --expose-internals +'use strict'; +const common = require('../common'); +const tmpdir = require('../common/tmpdir'); +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { validateRmdirOptions } = require('internal/fs/utils'); +let count = 0; + +tmpdir.refresh(); + +function makeNonEmptyDirectory() { + const dirname = `rmdir-recursive-${count}`; + fs.mkdirSync(path.join(dirname, 'foo', 'bar', 'baz'), { recursive: true }); + fs.writeFileSync(path.join(dirname, 'text.txt'), 'hello', 'utf8'); + count++; + return dirname; +} + +// Test the asynchronous version. +{ + const dir = makeNonEmptyDirectory(); + + // Removal should fail without the recursive option. + fs.rmdir(dir, common.mustCall((err) => { + assert.strictEqual(err.syscall, 'rmdir'); + + // Removal should fail without the recursive option set to true. + fs.rmdir(dir, { recursive: false }, common.mustCall((err) => { + assert.strictEqual(err.syscall, 'rmdir'); + + // Recursive removal should succeed. + fs.rmdir(dir, { recursive: true }, common.mustCall((err) => { + assert.ifError(err); + + // No error should occur if recursive and the directory does not exist. + fs.rmdir(dir, { recursive: true }, common.mustCall((err) => { + assert.ifError(err); + + // Attempted removal should fail now because the directory is gone. + fs.rmdir(dir, common.mustCall((err) => { + assert.strictEqual(err.syscall, 'rmdir'); + })); + })); + })); + })); + })); +} + +// Test the synchronous version. +{ + const dir = makeNonEmptyDirectory(); + + // Removal should fail without the recursive option set to true. + common.expectsError(() => { + fs.rmdirSync(dir); + }, { syscall: 'rmdir' }); + common.expectsError(() => { + fs.rmdirSync(dir, { recursive: false }); + }, { syscall: 'rmdir' }); + + // Recursive removal should succeed. + fs.rmdirSync(dir, { recursive: true }); + + // No error should occur if recursive and the directory does not exist. + fs.rmdirSync(dir, { recursive: true }); + + // Attempted removal should fail now because the directory is gone. + common.expectsError(() => fs.rmdirSync(dir), { syscall: 'rmdir' }); +} + +// Test the Promises based version. +(async () => { + const dir = makeNonEmptyDirectory(); + + // Removal should fail without the recursive option set to true. + assert.rejects(fs.promises.rmdir(dir), { syscall: 'rmdir' }); + assert.rejects(fs.promises.rmdir(dir, { recursive: false }), { + syscall: 'rmdir' + }); + + // Recursive removal should succeed. + await fs.promises.rmdir(dir, { recursive: true }); + + // No error should occur if recursive and the directory does not exist. + await fs.promises.rmdir(dir, { recursive: true }); + + // Attempted removal should fail now because the directory is gone. + assert.rejects(fs.promises.rmdir(dir), { syscall: 'rmdir' }); +})(); + +// Test input validation. +{ + const defaults = { + emfileWait: 1000, + maxBusyTries: 3, + recursive: false + }; + const modified = { + emfileWait: 953, + maxBusyTries: 5, + recursive: true + }; + + assert.deepStrictEqual(validateRmdirOptions(), defaults); + assert.deepStrictEqual(validateRmdirOptions({}), defaults); + assert.deepStrictEqual(validateRmdirOptions(modified), modified); + assert.deepStrictEqual(validateRmdirOptions({ + maxBusyTries: 99 + }), { + emfileWait: 1000, + maxBusyTries: 99, + recursive: false + }); + + [null, 'foo', 5, NaN].forEach((bad) => { + common.expectsError(() => { + validateRmdirOptions(bad); + }, { + code: 'ERR_INVALID_ARG_TYPE', + type: TypeError, + message: /^The "options" argument must be of type object\./ + }); + }); + + [undefined, null, 'foo', Infinity, function() {}].forEach((bad) => { + common.expectsError(() => { + validateRmdirOptions({ recursive: bad }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + type: TypeError, + message: /^The "recursive" argument must be of type boolean\./ + }); + }); + + common.expectsError(() => { + validateRmdirOptions({ emfileWait: -1 }); + }, { + code: 'ERR_OUT_OF_RANGE', + type: RangeError, + message: /^The value of "emfileWait" is out of range\./ + }); + + common.expectsError(() => { + validateRmdirOptions({ maxBusyTries: -1 }); + }, { + code: 'ERR_OUT_OF_RANGE', + type: RangeError, + message: /^The value of "maxBusyTries" is out of range\./ + }); +} diff --git a/tools/license-builder.sh b/tools/license-builder.sh index 7875a0cd24e4a4..c46b18845f18d1 100755 --- a/tools/license-builder.sh +++ b/tools/license-builder.sh @@ -106,4 +106,7 @@ addlicense "HdrHistogram" "deps/histogram" "$(cat ${rootdir}/deps/histogram/LICE addlicense "node-heapdump" "src/heap_utils.cc" \ "$(curl -sL https://raw.githubusercontent.com/bnoordhuis/node-heapdump/0ca52441e46241ffbea56a389e2856ec01c48c97/LICENSE)" +addlicense "rimraf" "lib/internal/fs/rimraf.js" \ + "$(curl -sL https://raw.githubusercontent.com/isaacs/rimraf/0e365ac4e4d64a25aa2a3cc026348f13410210e1/LICENSE)" + mv $tmplicense $licensefile