diff --git a/lib/internal/modules/cjs/helpers.js b/lib/internal/modules/cjs/helpers.js index 71abcbf880ebb1..9237949e998d88 100644 --- a/lib/internal/modules/cjs/helpers.js +++ b/lib/internal/modules/cjs/helpers.js @@ -56,8 +56,8 @@ const builtinLibs = [ 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'crypto', 'dgram', 'dns', 'domain', 'events', 'fs', 'http', 'http2', 'https', 'net', 'os', 'path', 'perf_hooks', 'punycode', 'querystring', 'readline', 'repl', - 'stream', 'string_decoder', 'tls', 'trace_events', 'tty', 'url', 'util', - 'v8', 'vm', 'worker_threads', 'zlib' + 'shutil', 'stream', 'string_decoder', 'tls', 'trace_events', 'tty', 'url', + 'util', 'v8', 'vm', 'worker_threads', 'zlib' ]; if (typeof internalBinding('inspector').open === 'function') { diff --git a/lib/internal/shutil.js b/lib/internal/shutil.js new file mode 100644 index 00000000000000..ad8b0ac944cd60 --- /dev/null +++ b/lib/internal/shutil.js @@ -0,0 +1,310 @@ +'use strict'; + +const fs = require('fs'); +const { join } = require('path'); +const { validatePath } = require('internal/fs/utils'); +const { setTimeout } = require('timers'); +const { + codes: { ERR_INVALID_ARG_TYPE, ERR_INVALID_CALLBACK } +} = require('internal/errors'); +const isWindows = process.platform === 'win32'; +const _0666 = parseInt('666', 8); + +// For EMFILE handling +let timeout = 0; + +function rmtree(path, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + validatePath(path); + validateCallback(callback); + + options = getOptions(options); + + let busyTries = 0; + + rmtree_(path, options, function CB(er) { + if (er) { + if ( + (er.code === 'EBUSY' || + er.code === 'ENOTEMPTY' || + er.code === 'EPERM') && + busyTries < options.maxBusyTries + ) { + busyTries++; + const time = busyTries * 100; + // Try again, with the same exact callback as this one. + return setTimeout(function() { + rmtree_(path, options, CB); + }, time); + } + + // This one won't happen if graceful-fs is used. + if (er.code === 'EMFILE' && timeout < options.emfileWait) { + return setTimeout(function() { + rmtree_(path, options, CB); + }, timeout++); + } + + // Already gone + if (er.code === 'ENOENT') er = null; + + callback(er); + } + + timeout = 0; + callback(); + }); +} + +// Two possible strategies. +// 1. Assume it's a file. unlink it, then do the dir stuff on EPERM or EISDIR +// 2. Assume it's a directory. readdir, then do the file stuff on ENOTDIR +// +// Both result in an extra syscall when you guess wrong. However, there +// are likely far more normal files in the world than directories. This +// is based on the assumption that a the average number of files per +// directory is >= 1. +// +// If anyone ever complains about this, then I guess the strategy could +// be made configurable somehow. But until then, YAGNI. +function rmtree_(path, options, callback) { + validatePath(path); + validateOptions(options); + validateCallback(callback); + + // Sunos lets the root user unlink directories, which is... weird. + // So we have to lstat here and make sure it's not a dir. + options.lstat(path, function(er, st) { + if (er && er.code === 'ENOENT') return callback(null); + + // Windows can EPERM on stat. Life is suffering. + if (er && er.code === 'EPERM' && isWindows) { + fixWinEPERM(path, options, er, callback); + } + + if (st && st.isDirectory()) return rmdir(path, options, er, callback); + + options.unlink(path, function(er) { + if (er) { + if (er.code === 'ENOENT') return callback(null); + if (er.code === 'EPERM') + return isWindows ? + fixWinEPERM(path, options, er, callback) : + rmdir(path, options, er, callback); + if (er.code === 'EISDIR') return rmdir(path, options, er, callback); + } + return callback(er); + }); + }); +} + +// This looks simpler, and is strictly *faster*, but will +// tie up the JavaScript thread and fail on excessively +// deep directory trees. +function rmtreeSync(path, options) { + options = options || {}; + options = getOptions(options); + + validatePath(path); + + let st; + + try { + st = options.lstatSync(path); + } catch (er) { + if (er.code === 'ENOENT') return; + + // Windows can EPERM on stat. Life is suffering. + if (er.code === 'EPERM' && isWindows) fixWinEPERMSync(path, options, er); + } + + try { + // Sunos lets the root user unlink directories, which is... weird. + if (st && st.isDirectory()) rmdirSync(path, options, null); + else options.unlinkSync(path); + } catch (er) { + if (er.code === 'ENOENT') return; + if (er.code === 'EPERM') + return isWindows ? + fixWinEPERMSync(path, options, er) : + rmdirSync(path, options, er); + if (er.code !== 'EISDIR') throw er; + + rmdirSync(path, options, er); + } +} + +function validateOptions(options) { + if (typeof options !== 'object') { + throw new ERR_INVALID_ARG_TYPE('options', 'Object', options); + } +} + +function validateCallback(callback) { + if (typeof callback === 'function') { + return callback; + } + + throw new ERR_INVALID_CALLBACK(callback); +} + +function validateError(error) { + if (!(error instanceof Error)) { + throw new ERR_INVALID_ARG_TYPE('error', 'Error', error); + } +} + +function getOptions(options) { + const methods = ['unlink', 'chmod', 'stat', 'lstat', 'rmdir', 'readdir']; + + validateOptions(options); + + methods.forEach(function(method) { + options[method] = options[method] || fs[method]; + method = method + 'Sync'; + options[method] = options[method] || fs[method]; + }); + + options.maxBusyTries = options.maxBusyTries || 3; + options.emfileWait = options.emfileWait || 1000; + + return options; +} + +function fixWinEPERM(path, options, error, callback) { + validatePath(path); + validateOptions(options); + if (error) validateError(error); + validateCallback(callback); + + options.chmod(path, _0666, function(er2) { + if (er2) callback(er2.code === 'ENOENT' ? null : error); + else + options.stat(path, function(er3, stats) { + if (er3) callback(er3.code === 'ENOENT' ? null : error); + else if (stats.isDirectory()) rmdir(path, options, error, callback); + else options.unlink(path, callback); + }); + }); +} + +function fixWinEPERMSync(path, options, error) { + validatePath(path); + validateOptions(options); + if (error) validateError(error); + + let stats; + + try { + options.chmodSync(path, _0666); + } catch (er2) { + if (er2.code === 'ENOENT') return; + else throw error; + } + + try { + stats = options.statSync(path); + } catch (er3) { + if (er3.code === 'ENOENT') return; + else throw error; + } + + if (stats.isDirectory()) rmdirSync(path, options, error); + else options.unlinkSync(path); +} + +function rmdir(path, options, originalEr, callback) { + validatePath(path); + validateOptions(options); + if (originalEr) validateError(originalEr); + validateCallback(callback); + + // Try to rmdir first, and only readdir on ENOTEMPTY or EEXIST (SunOS) + // if we guessed wrong, and it's not a directory, then + // raise the original error. + options.rmdir(path, function(er) { + if (er && + (er.code === 'ENOTEMPTY' || + er.code === 'EEXIST' || + er.code === 'EPERM') + ) + rmkids(path, options, callback); + else if (er && er.code === 'ENOTDIR') callback(originalEr); + else callback(er); + }); +} + +function rmdirSync(path, options, originalEr) { + validatePath(path); + validateOptions(options); + if (originalEr) validateError(originalEr); + + try { + options.rmdirSync(path); + } catch (er) { + if (er.code === 'ENOENT') return; + if (er.code === 'ENOTDIR') throw originalEr; + if (er.code === 'ENOTEMPTY' || er.code === 'EEXIST' || er.code === 'EPERM') + rmkidsSync(path, options); + } +} + +function rmkids(path, options, callback) { + validatePath(path); + validateOptions(options); + validateCallback(callback); + + options.readdir(path, function(er, files) { + if (er) return callback(er); + var n = files.length; + if (n === 0) return options.rmdir(path, callback); + var errState; + files.forEach(function(f) { + rmtree(join(path, f), options, function(er) { + if (errState) return; + if (er) return callback((errState = er)); + if (--n === 0) options.rmdir(path, callback); + }); + }); + }); +} + +function rmkidsSync(path, options) { + validatePath(path); + validateOptions(options); + + options.readdirSync(path).forEach(function(f) { + rmtreeSync(join(path, f), options); + }); + + // We only end up here once we got ENOTEMPTY at least once, and + // at this point, we are guaranteed to have removed all the kids. + // So, we know that it won't be ENOENT or ENOTDIR or anything else. + // try really hard to delete stuff on windows, because it has a + // PROFOUNDLY annoying habit of not closing handles promptly when + // files are deleted, resulting in spurious ENOTEMPTY errors. + var retries = isWindows ? 100 : 1; + var i = 0; + do { + var threw = true; + try { + var ret = options.rmdirSync(path, options); + threw = false; + return ret; + } finally { + // This is taken directly from rimraf. Fixing the lint error could + // subtly change the behavior + // eslint-disable-next-line + if (++i < retries && threw) continue; + } + } while (true); +} + +module.exports = { + rmtree, + rmtreeSync +}; diff --git a/lib/shutil.js b/lib/shutil.js new file mode 100644 index 00000000000000..811b8fa4f92c69 --- /dev/null +++ b/lib/shutil.js @@ -0,0 +1,8 @@ +'use strict'; + +const { rmtree, rmtreeSync } = require('internal/shutil'); + +module.exports = { + rmtree, + rmtreeSync +}; diff --git a/node.gyp b/node.gyp index 56377d6a0c5e8e..3413c33c273274 100644 --- a/node.gyp +++ b/node.gyp @@ -66,6 +66,7 @@ 'lib/querystring.js', 'lib/readline.js', 'lib/repl.js', + 'lib/shutil.js', 'lib/stream.js', 'lib/_stream_readable.js', 'lib/_stream_writable.js', @@ -194,6 +195,7 @@ 'lib/internal/v8_prof_polyfill.js', 'lib/internal/v8_prof_processor.js', 'lib/internal/validators.js', + 'lib/internal/shutil.js', 'lib/internal/stream_base_commons.js', 'lib/internal/vm/source_text_module.js', 'lib/internal/worker.js', diff --git a/test/parallel/test-shutil-rmtree.js b/test/parallel/test-shutil-rmtree.js new file mode 100644 index 00000000000000..df1ce8d7364f38 --- /dev/null +++ b/test/parallel/test-shutil-rmtree.js @@ -0,0 +1,72 @@ +'use strict'; +require('../common'); +const tmpdir = require('../common/tmpdir'); + +const fs = require('fs'); +const path = require('path'); +const assert = require('assert'); +const shutil = require('shutil'); + +const tmpDir = tmpdir.path; +const baseDir = path.join(tmpDir, 'shutil-rmtree'); + +function setupFiles() { + fs.mkdirSync(baseDir); + fs.mkdirSync(path.join(baseDir, 'dir1')); + fs.mkdirSync(path.join(baseDir, 'dir2')); + fs.mkdirSync(path.join(baseDir, 'dir2', 'dir3')); + fs.mkdirSync(path.join(baseDir, 'dir4')); + + fs.closeSync(fs.openSync(path.join(baseDir, 'dir1', 'file1'), 'w')); + fs.closeSync(fs.openSync(path.join(baseDir, 'dir1', 'file2'), 'w')); + fs.closeSync(fs.openSync(path.join(baseDir, 'dir2', 'file3'), 'w')); + fs.closeSync(fs.openSync(path.join(baseDir, 'dir2', 'dir3', 'file4'), 'w')); + fs.closeSync(fs.openSync(path.join(baseDir, 'dir2', 'dir3', 'file5'), 'w')); +} + +function assertGone(path) { + assert.throws(() => fs.readdirSync(path), { + code: 'ENOENT' + }); +} + +function test_rmtree_sync(path) { + setupFiles(); + + assert(fs.readdirSync(path)); + + shutil.rmtreeSync(path); + assertGone(path); +} + +function test_invalid_path() { + assert.throws(() => shutil.rmtreeSync(1), { + code: 'ERR_INVALID_ARG_TYPE' + }); +} + +function test_invalid_options(path) { + assert.throws(() => shutil.rmtreeSync(path, 1), { + code: 'ERR_INVALID_ARG_TYPE' + }); +} + +function test_invalid_callback(path) { + assert.throws(() => shutil.rmtree(path, {}, 1), { + code: 'ERR_INVALID_CALLBACK' + }); +} + +function test_rmtree(path) { + setupFiles(); + + assert(fs.readdirSync(path)); + + shutil.rmtree(path, () => assertGone(path)); +} + +test_rmtree_sync(baseDir); +test_invalid_path(); +test_invalid_options(baseDir); +test_invalid_callback(baseDir); +test_rmtree(baseDir);