diff --git a/doc/api/fs.md b/doc/api/fs.md index 7e8266a90ec63a..5c3dc153eedc2e 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -1318,7 +1318,7 @@ this API: [`fs.chmod()`][]. See also: chmod(2). -## fs.chown(path, uid, gid, callback) +## fs.chown(path, uid, gid[, options], callback) +> Stability: 1 - Recursive chown is experimental. + * `path` {string|Buffer|URL} * `uid` {integer} * `gid` {integer} +* `options` {object} + * `recursive` {boolean} If `true`, perform a recursive directory chown. In + recursive mode, errors are not reported if `path` does not exist, and + operations are retried on failure. **Default:** `false`. * `callback` {Function} * `err` {Error} @@ -1347,7 +1353,7 @@ possible exception are given to the completion callback. See also: chown(2). -## fs.chownSync(path, uid, gid) +## fs.chownSync(path, uid, gid[, options]) +> Stability: 1 - Recursive removal is experimental. + * `path` {string|Buffer|URL} * `uid` {integer} * `gid` {integer} +* `options` {Object} + * `recursive` {boolean} If `true`, perform a recursive directory chown. In + recursive mode, errors are not reported if `path` does not exist, and + operations are retried on failure. **Default:** `false`. Synchronously changes owner and group of a file. Returns `undefined`. This is the synchronous version of [`fs.chown()`][]. diff --git a/lib/fs.js b/lib/fs.js index 5c7d907f5e546d..9c6a1e1f878bf6 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -76,8 +76,9 @@ const { validateOffsetLengthRead, validateOffsetLengthWrite, validatePath, + validateChownOptions, validateRmdirOptions, - warnOnNonPortableTemplate + warnOnNonPortableTemplate, } = require('internal/fs/utils'); const { CHAR_FORWARD_SLASH, @@ -103,6 +104,8 @@ let ReadStream; let WriteStream; let rimraf; let rimrafSync; +let chownR; +let chownRSync; // These have to be separate because of how graceful-fs happens to do it's // monkeypatching. @@ -745,6 +748,12 @@ function lazyLoadRimraf() { ({ rimraf, rimrafSync } = require('internal/fs/rimraf')); } +function lazyLoadChownR() { + if (chownR === undefined) + ({ chownR, + chownRSync } = require('internal/fs/chown_recursive')); +} + function rmdir(path, options, callback) { if (typeof options === 'function') { callback = options; @@ -1170,21 +1179,38 @@ function fchownSync(fd, uid, gid) { handleErrorFromBinding(ctx); } -function chown(path, uid, gid, callback) { +function chown(path, uid, gid, options, callback) { callback = makeCallback(callback); path = getValidatedPath(path); validateUint32(uid, 'uid'); validateUint32(gid, 'gid'); + callback = makeCallback(callback); + path = pathModule.toNamespacedPath(getValidatedPath(path)); + options = validateChownOptions(options); + + if (options.recursive) { + lazyLoadChownR(); + chownR(path, uid, gid, options, callback); + } + const req = new FSReqCallback(); req.oncomplete = callback; binding.chown(pathModule.toNamespacedPath(path), uid, gid, req); } -function chownSync(path, uid, gid) { +function chownSync(path, uid, gid, options) { path = getValidatedPath(path); validateUint32(uid, 'uid'); validateUint32(gid, 'gid'); + + options = validateChownOptions(options); + + if (options.recursive) { + lazyLoadChownR(); + chownRSync(path, uid, gid, options); + } + const ctx = { path }; binding.chown(pathModule.toNamespacedPath(path), uid, gid, undefined, ctx); handleErrorFromBinding(ctx); diff --git a/lib/internal/fs/chown_recursive.js b/lib/internal/fs/chown_recursive.js new file mode 100644 index 00000000000000..540af8a758afbf --- /dev/null +++ b/lib/internal/fs/chown_recursive.js @@ -0,0 +1,106 @@ +'use strict'; + +const { + readdir, + readdirSync, + lstat, + lstatSync, + chown, + chownSync +} = require('fs'); + +const { join, resolve } = require('path'); +const notEmptyErrorCodes = new Set(['EEXIST']); +const { setTimeout } = require('timers'); + +function chownRSync(path, uid, gid, options) { + const stats = lstatSync(path); + if (stats !== undefined && stats.isDirectory()) { + const childrenPaths = readdirSync(path, { withFileTypes: true }); + childrenPaths.forEach((childPath) => + _chownRChildrenSync(path, childPath, uid, gid, options)); + } else { + chownSync(path, uid, gid); + } +} + +function _chownRChildrenSync(path, childPath, uid, gid, options) { + if (typeof childPath === 'string') { + const stats = lstatSync(resolve(path, childPath)); + stats.name = childPath; + childPath = stats; + } + + if (childPath.isDirectory()) { + chownRSync(resolve(path, childPath.name), uid, gid, options); + } + + chownSync(resolve(path, childPath.name), uid, gid); +} + +function _chownChildren(path, uid, gid, options, callback) { + readdir(path, (err, files) => { + if (err) + return callback(err); + + let numFiles = files.length; + + if (numFiles === 0) + return chown(path, uid, gid, callback); + + let done = false; + + files.forEach((child) => { + chownR(join(path, child), uid, gid, options, (err) => { + if (done) + return; + + if (err) { + done = true; + return callback(err); + } + + numFiles--; + if (numFiles === 0) + chown(path, uid, gid, callback); + }); + }); + }); +} + +function _chown(path, uid, gid, options, originalErr, callback) { + chown(path, (err) => { + if (err) { + if (notEmptyErrorCodes.has(err.code)) + return _chownChildren(path, uid, gid, options, callback); + if (err.code === 'ENOTDIR') + return callback(originalErr); + } + callback(err); + }); +} + +function _chownR(path, uid, gid, options, callback) { + lstat(path, (err, stats) => { + if (err) { + if (err.code === 'ENOENT') + return callback(null); + } else if (stats.isDirectory()) { + return _chown(path, uid, gid, options, err, callback); + } + }); +} + +function chownR(path, uid, gid, options, callback) { + let timeout = 0; // For EMFILE handling. + _chownR(path, uid, gid, options, function CB(err) { + if (err) { + if (err.code === 'EMFILE') + return setTimeout(_chownR, timeout++, path, options, CB); + } + callback(err); + }); +} + + +module.exports = { chownRSync, chownR }; diff --git a/lib/internal/fs/utils.js b/lib/internal/fs/utils.js index fb060d23e66ffc..2497a039067e24 100644 --- a/lib/internal/fs/utils.js +++ b/lib/internal/fs/utils.js @@ -552,6 +552,26 @@ const validateRmdirOptions = hideStackFrames((options) => { return options; }); +// @TODO: Add additional options to match chown cli utility. +const defaultChownOptions = { + recursive: false, +}; + +const validateChownOptions = hideStackFrames((options) => { + if (options === undefined) + return defaultChownOptions; + if (options === null || typeof options !== 'object') + throw new ERR_INVALID_ARG_TYPE('options', 'object', options); + + options = { ...defaultChownOptions, ...options }; + + if (typeof options.recursive !== 'boolean') + throw new ERR_INVALID_ARG_TYPE('recursive', 'boolean', options.recursive); + + return options; + +}); + module.exports = { assertEncoding, @@ -560,19 +580,20 @@ module.exports = { Dirent, getDirents, getOptions, + getStatsFromBinding, getValidatedPath, nullCheck, preprocessSymlinkDestination, realpathCacheKey: Symbol('realpathCacheKey'), - getStatsFromBinding, + Stats, stringToFlags, stringToSymlinkType, - Stats, toUnixTimestamp, validateBufferArray, + validateChownOptions, validateOffsetLengthRead, validateOffsetLengthWrite, validatePath, validateRmdirOptions, - warnOnNonPortableTemplate + warnOnNonPortableTemplate, }; diff --git a/node.gyp b/node.gyp index 1d45f5117144b0..8af9cfe36d6c59 100644 --- a/node.gyp +++ b/node.gyp @@ -125,6 +125,7 @@ 'lib/internal/fs/promises.js', 'lib/internal/fs/read_file_context.js', 'lib/internal/fs/rimraf.js', + 'lib/internal/fs/chown_recursive.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-chown-recursive.js b/test/parallel/test-fs-chown-recursive.js new file mode 100644 index 00000000000000..2c8a663b0ab55b --- /dev/null +++ b/test/parallel/test-fs-chown-recursive.js @@ -0,0 +1,113 @@ +// 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 { validateChownOptions } = require('internal/fs/utils'); + +const dirname = 'chown-recursive'; + +/** + * Temporary dir structure + * + * .tmp.0 + * └── chown-recursive + * └── foo + * ├── bar + * │   └── file1.test + * └── file2.test + */ + +const paths = [ + 'bar/file1.test', + 'bar', + 'file2.test', + '.' // refers to foo +]; + +const expectUID = 1; +const expectGID = 1; + +const mainPath = path.join(tmpdir.path, dirname); +const fooPath = path.resolve(mainPath, 'foo'); + +function makeDirectories() { + fs.mkdirSync(fooPath, { recursive: true }); + fs.mkdirSync(path.resolve(fooPath, 'bar')); + fs.writeFileSync(path.resolve(fooPath, 'bar', 'file1.test'), 'file1'); + fs.writeFileSync(path.resolve(fooPath, 'file2.test'), 'file2'); +} + + +// Test input validation. +{ + const defaults = { + recursive: false + }; + const modified = { + recursive: true + }; + + assert.deepStrictEqual(validateChownOptions(), defaults); + assert.deepStrictEqual(validateChownOptions({}), defaults); + assert.deepStrictEqual(validateChownOptions(modified), modified); + assert.deepStrictEqual(validateChownOptions({ + }), { + recursive: false + }); + + [null, 'foo', 5, NaN].forEach((badArg) => { + common.expectsError(() => { + validateChownOptions(badArg); + }, { + code: 'ERR_INVALID_ARG_TYPE', + type: TypeError, + message: /^The "options" argument must be of type object\./ + }); + }); + + [undefined, null, 'foo', Infinity, function() {}].forEach((badValue) => { + common.expectsError(() => { + validateChownOptions({ recursive: badValue }); + }, { + code: 'ERR_INVALID_ARG_TYPE', + type: TypeError, + message: /^The "recursive" argument must be of type boolean\./ + }); + }); +} + +// Test the synchronous version. +{ + + makeDirectories(); + + // Recursive chown should succeed. + fs.chownSync(fooPath, expectUID, expectGID, { recursive: true }); + + paths.forEach((p) => { + const pathToEvaluate = path.join(fooPath, p); + const stat = fs.lstatSync(pathToEvaluate); + assert.strictEqual(stat.uid, expectUID, + `uid for ${p} should equal ${expectUID} \ + for path ${pathToEvaluate}`); + assert.strictEqual(stat.gid, expectGID, + `gid for ${p} should equal ${expectGID} \ + for path ${pathToEvaluate}`); + }); +} +/* +// test async version. +{ + makeDirectories(); + fs.chown(tmpdir.path, expectUID, expectGID, { recursive: true }, common.mustCall(() => { + filePaths.forEach((pathToCheck) => { + const stat = fs.lstatSync(pathToCheck); + assert.ok(stat.uid === expectUID, `uid for ${pathToCheck} should equal ${expectUID}`); + assert.ok(stat.gid === expectGID, `gid for ${pathToCheck} should equal ${expectGID}`); + }); + }, 1)); +} +*/