From b5335099b2dc1b1001b0a7ad61a24dff619af85f Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Sat, 22 Aug 2026 14:40:26 +0000 Subject: [PATCH 1/2] fs: give directories created by cpSync the source directory's mode The C++ fast path that fs.cpSync() takes when no filter is given created the destination directories with default permissions, so a 0700 directory came out of the copy as 0755 (with the default umask). The JavaScript implementation, which fs.cp(), fs.promises.cp() and fs.cpSync() with a filter still use, chmod()s every directory it creates to the mode of its source, and so did cpSync before the port. Set the source directory's permissions on each directory the copy creates (the destination root included); directories that already exist keep theirs, as before. Signed-off-by: Shelley Vohr --- src/node_file.cc | 28 ++++++++- .../test-fs-cp-sync-directory-mode.mjs | 61 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 test/parallel/test-fs-cp-sync-directory-mode.mjs diff --git a/src/node_file.cc b/src/node_file.cc index 871d8f16bd3..92dd370ac5b 100644 --- a/src/node_file.cc +++ b/src/node_file.cc @@ -4017,6 +4017,7 @@ static void CpSyncCopyDir(const FunctionCallbackInfo& args) { auto dest_path = dest.ToPath(); std::error_code error; + const bool dest_existed = std::filesystem::exists(dest_path, error); std::filesystem::create_directories(dest_path, error); if (error) { return env->ThrowStdErrException(error, "cp", *dest); @@ -4136,11 +4137,28 @@ static void CpSyncCopyDir(const FunctionCallbackInfo& args) { } } else if (dir_entry.is_directory()) { auto entry_dir_path = src / dir_entry.path().filename(); - std::filesystem::create_directory(dest_file_path); + const bool created = + std::filesystem::create_directory(dest_file_path, error); + if (error) { + env->ThrowStdErrException( + error, "cp", ConvertPathToUTF8(dest_file_path).c_str()); + return false; + } auto success = copy_dir_contents(entry_dir_path, dest_file_path); if (!success) { return false; } + // A directory created by the copy gets the mode of its source once + // its contents are in (the source may be read-only). + if (created) { + std::filesystem::permissions( + dest_file_path, dir_entry.status().permissions(), error); + if (error) { + env->ThrowStdErrException( + error, "cp", ConvertPathToUTF8(dest_file_path).c_str()); + return false; + } + } } else if (dir_entry.is_regular_file()) { std::filesystem::copy_file( dir_entry.path(), dest_file_path, file_copy_opts, error); @@ -4165,7 +4183,13 @@ static void CpSyncCopyDir(const FunctionCallbackInfo& args) { return true; }; - copy_dir_contents(src_path, dest_path); + if (copy_dir_contents(src_path, dest_path) && !dest_existed) { + std::filesystem::permissions( + dest_path, std::filesystem::status(src_path).permissions(), error); + if (error) { + return env->ThrowStdErrException(error, "cp", *dest); + } + } } BindingData::FilePathIsFileReturnType BindingData::FilePathIsFile( diff --git a/test/parallel/test-fs-cp-sync-directory-mode.mjs b/test/parallel/test-fs-cp-sync-directory-mode.mjs new file mode 100644 index 00000000000..a6c54d48e86 --- /dev/null +++ b/test/parallel/test-fs-cp-sync-directory-mode.mjs @@ -0,0 +1,61 @@ +// This tests that cpSync gives the directories it creates the mode of the +// corresponding source directory, as cp does. +import { mustNotMutateObjectDeep, isWindows, skip } from '../common/index.mjs'; +import { nextdir } from '../common/fs.js'; +import assert from 'node:assert'; +import { chmodSync, cpSync, mkdirSync, statSync, writeFileSync, promises } from 'node:fs'; +import { join } from 'node:path'; +import tmpdir from '../common/tmpdir.js'; + +if (isWindows) + skip('directory modes are not meaningful on Windows'); + +tmpdir.refresh(); +const mask = process.umask(0o022); + +const src = nextdir(); +mkdirSync(join(src, 'private', 'inner'), { recursive: true, mode: 0o700 }); +mkdirSync(join(src, 'shared'), { mode: 0o775 }); +writeFileSync(join(src, 'private', 'inner', 'file'), 'x', { mode: 0o600 }); + +function modes(root) { + return ['.', 'private', 'private/inner', 'shared', 'private/inner/file'] + .map((p) => (statSync(join(root, p)).mode & 0o777).toString(8)); +} + +const destSync = nextdir(); +cpSync(src, destSync, mustNotMutateObjectDeep({ recursive: true })); +assert.deepStrictEqual(modes(destSync), modes(src)); + +const destAsync = nextdir(); +await promises.cp(src, destAsync, { recursive: true }); +assert.deepStrictEqual(modes(destAsync), modes(src)); + +// A read-only source directory can still be copied; its copy ends up read-only too. +{ + const roSrc = nextdir(); + mkdirSync(join(roSrc, 'sub'), { recursive: true }); + writeFileSync(join(roSrc, 'sub', 'file'), 'x'); + chmodSync(join(roSrc, 'sub'), 0o555); + chmodSync(roSrc, 0o555); + const readOnly = [roSrc, join(roSrc, 'sub')]; + for (const copy of [(dest) => cpSync(roSrc, dest, { recursive: true }), + (dest) => promises.cp(roSrc, dest, { recursive: true })]) { + const dest = nextdir(); + await copy(dest); + assert.strictEqual(statSync(join(dest, 'sub', 'file')).size, 1); + assert.deepStrictEqual( + [dest, join(dest, 'sub')].map((p) => (statSync(p).mode & 0o777).toString(8)), ['555', '555']); + readOnly.push(dest, join(dest, 'sub')); + } + // Let tmpdir clean up. + for (const dir of readOnly) chmodSync(dir, 0o755); +} + +// An existing destination directory keeps its own mode. +const existing = nextdir(); +mkdirSync(existing, { mode: 0o711 }); +cpSync(src, existing, mustNotMutateObjectDeep({ recursive: true })); +assert.strictEqual((statSync(existing).mode & 0o777).toString(8), '711'); +assert.deepStrictEqual(modes(existing).slice(1), modes(src).slice(1)); +process.umask(mask); From 45406cc8b7487cabb241fe5a1f04cfa5d4b6d4fa Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Sat, 22 Aug 2026 14:50:37 +0000 Subject: [PATCH 2/2] fs: copy directory trees for fs.cp() on the thread pool fs.cp() and fs.promises.cp() walked the tree in JavaScript with several thread pool round trips per entry (opendir batches, two stat()s, the copyFile(), a chmod()), all awaited in sequence: a 2 100-file tree took ~215 ms with ~110 ms of that on the main thread, against ~36 ms for fs.cpSync(), which copies the tree in C++ when no filter is given. Factor that C++ walk into CopyDirRecursive(), which records the error instead of throwing so that it can run on any thread, and run it as one ThreadPoolWork request (CpDirJob) for fs.cp()/fs.promises.cp() when the destination directory does not exist yet and nothing has to run per entry (no filter, no dereference, permission model off). Copying into an existing tree keeps the JavaScript walk and its rules for what may already be there. The same tree now takes ~30 ms with under 1 ms on the main thread. For that job the walk follows the JavaScript walk's rules rather than cpSync's: it creates every directory with mkdir() and every file with an exclusive uv_fs_copyfile() (honouring the copyFile() mode flags) and fails with EEXIST if anything has appeared in their place since the JavaScript check, so it never opens or follows something it did not create; sockets, FIFOs and unknown entries are reported back to JavaScript, which rejects them with the same SystemErrors as before; relative link targets are made absolute lexically as path.resolve() does. cpSync keeps merging into existing directories, skipping special files and canonicalizing link targets. The walk now uses the error_code overloads of std::filesystem throughout (directory iteration included), so an unreadable directory inside the tree is reported as EACCES by both cp() and cpSync() instead of terminating the process, which cpSync() has done since the walk moved to C++. Filesystem errors raised inside the walk keep their codes, with 'cp' or 'copyfile'/'mkdir' as the syscall. Signed-off-by: Shelley Vohr --- benchmark/fs/bench-cp.js | 33 + lib/internal/fs/cp/cp.js | 68 +- src/node_file.cc | 659 +++++++++++++----- ...t-fs-cp-async-destination-appears-late.mjs | 35 + ...test-fs-cp-async-special-files-in-tree.mjs | 53 ++ .../test-fs-cp-async-symlink-targets.mjs | 29 + .../test-fs-cp-async-with-mode-flags.mjs | 11 + .../test-fs-cp-unreadable-directory.mjs | 33 + 8 files changed, 709 insertions(+), 212 deletions(-) create mode 100644 benchmark/fs/bench-cp.js create mode 100644 test/parallel/test-fs-cp-async-destination-appears-late.mjs create mode 100644 test/parallel/test-fs-cp-async-special-files-in-tree.mjs create mode 100644 test/parallel/test-fs-cp-async-symlink-targets.mjs create mode 100644 test/parallel/test-fs-cp-unreadable-directory.mjs diff --git a/benchmark/fs/bench-cp.js b/benchmark/fs/bench-cp.js new file mode 100644 index 00000000000..ffaeb87705f --- /dev/null +++ b/benchmark/fs/bench-cp.js @@ -0,0 +1,33 @@ +'use strict'; + +// fs.promises.cp() of a directory tree. + +const common = require('../common'); +const fs = require('fs'); +const path = require('path'); +const tmpdir = require('../../test/common/tmpdir'); + +const bench = common.createBenchmark(main, { + files: [500], + n: [3], +}); + +function prepareSource(files) { + const src = tmpdir.resolve('cp-src'); + for (let i = 0; i < files; i++) { + const dir = path.join(src, `dir-${i % 10}`, `sub-${i % 7}`); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `file-${i}.js`), 'x'.repeat(1024 + (i % 512))); + } + return src; +} + +async function main({ files, n }) { + tmpdir.refresh(); + const src = prepareSource(files); + bench.start(); + for (let i = 0; i < n; i++) { + await fs.promises.cp(src, tmpdir.resolve(`cp-dest-${i}`), { recursive: true }); + } + bench.end(n); +} diff --git a/lib/internal/fs/cp/cp.js b/lib/internal/fs/cp/cp.js index 10c52b11463..7b4ea827088 100644 --- a/lib/internal/fs/cp/cp.js +++ b/lib/internal/fs/cp/cp.js @@ -6,6 +6,8 @@ const { ArrayPrototypeEvery, ArrayPrototypeFilter, Boolean, + ErrorCaptureStackTrace, + Promise, PromisePrototypeThen, PromiseReject, SafePromiseAll, @@ -55,6 +57,7 @@ const { sep, } = require('path'); const fsBinding = internalBinding('fs'); +const permission = require('internal/process/permission'); async function cpFn(src, dest, opts) { // Warn about using preserveTimestamps on 32-bit node @@ -211,30 +214,19 @@ async function getStatsForCopy(destStat, src, dest, opts) { return onFile(srcStat, destStat, src, dest, opts); } else if (srcStat.isSymbolicLink()) { return onLink(destStat, src, dest, opts); - } else if (srcStat.isSocket()) { - throw new ERR_FS_CP_SOCKET({ - message: `cannot copy a socket file: ${dest}`, - path: dest, - syscall: 'cp', - errno: EINVAL, - code: 'EINVAL', - }); - } else if (srcStat.isFIFO()) { - throw new ERR_FS_CP_FIFO_PIPE({ - message: `cannot copy a FIFO pipe: ${dest}`, - path: dest, - syscall: 'cp', - errno: EINVAL, - code: 'EINVAL', - }); } - throw new ERR_FS_CP_UNKNOWN({ - message: `cannot copy an unknown file type: ${dest}`, - path: dest, - syscall: 'cp', - errno: EINVAL, - code: 'EINVAL', - }); + throw errorForSpecialFile(srcStat.isSocket() ? 'socket' : srcStat.isFIFO() ? 'fifo' : 'unknown', dest); +} + +function errorForSpecialFile(kind, dest) { + const info = { path: dest, syscall: 'cp', errno: EINVAL, code: 'EINVAL' }; + if (kind === 'socket') { + return new ERR_FS_CP_SOCKET({ message: `cannot copy a socket file: ${dest}`, ...info }); + } + if (kind === 'fifo') { + return new ERR_FS_CP_FIFO_PIPE({ message: `cannot copy a FIFO pipe: ${dest}`, ...info }); + } + return new ERR_FS_CP_UNKNOWN({ message: `cannot copy an unknown file type: ${dest}`, ...info }); } function onFile(srcStat, destStat, src, dest, opts) { @@ -315,11 +307,41 @@ async function onDir(srcStat, destStat, src, dest, opts) { } async function mkDirAndCopy(srcMode, src, dest, opts) { + // A destination directory that does not exist yet is filled in one thread + // pool request by the walk fs.cpSync() uses, unless a filter has to run per + // entry, links inside the tree must be dereferenced, or the permission model + // has to check each path. Copying into an existing tree keeps the per-entry + // walk below and its rules for what may already be there. + if (!opts.filter && !opts.dereference && !permission.isEnabled()) { + // Creates dest itself, with the mode of src. + return copyDirNative(src, dest, opts); + } await mkdir(dest); await copyDir(src, dest, opts); return setDestMode(dest, srcMode); } +function copyDirNative(src, dest, opts) { + return new Promise((resolve, reject) => { + const job = new fsBinding.CpDirJob(src, dest, opts.force, opts.dereference, opts.errorOnExist, + opts.verbatimSymlinks, opts.preserveTimestamps, opts.mode); + // Sockets, FIFOs and unknown entries come back as (kind, path) so that + // they reject with the same errors as the walk above. + job.ondone = (err, specialFile, specialFilePath) => { + if (specialFile !== undefined) { + err = errorForSpecialFile(specialFile, specialFilePath); + } + if (err != null) { + ErrorCaptureStackTrace(err, copyDirNative); + reject(err); + } else { + resolve(); + } + }; + job.run(); + }); +} + async function copyDir(src, dest, opts) { const dir = await opendir(src); diff --git a/src/node_file.cc b/src/node_file.cc index 92dd370ac5b..eb9ae83d8f1 100644 --- a/src/node_file.cc +++ b/src/node_file.cc @@ -3882,17 +3882,100 @@ static void CpSyncCheckPaths(const FunctionCallbackInfo& args) { } } -static bool CopyUtimes(const std::filesystem::path& src, - const std::filesystem::path& dest, - Environment* env) { +std::vector normalizePathToArray( + const std::filesystem::path& path) { + std::vector parts; + std::error_code error; + std::filesystem::path absPath = std::filesystem::absolute(path, error); + if (error) absPath = path; +#ifdef _WIN32 + auto wstr = absPath.wstring(); + if (wstr.starts_with(L"\\\\?\\")) { + absPath = std::filesystem::path(wstr.substr(4)); + } +#endif + for (const auto& part : absPath) { + if (!part.empty()) parts.push_back(part.string()); + } + return parts; +} + +bool isInsideDir(const std::filesystem::path& src, + const std::filesystem::path& dest) { + auto srcArr = normalizePathToArray(src); + auto destArr = normalizePathToArray(dest); + if (srcArr.size() > destArr.size()) return false; + return std::equal(srcArr.begin(), srcArr.end(), destArr.begin()); +} + +namespace { + +// An fs.cp error recorded on whatever thread performed the copy; Throw() / +// ToException() turn it into the error the JavaScript caller sees. +struct CpError { + enum Kind { + kNone, + kErrno, + kUv, + kEinval, + kSymlinkToSubdirectory, + kEexist, + kSocket, + kFifo, + kUnknown + }; + Kind kind = kNone; + int code = 0; + const char* syscall = "cp"; + std::string message; + std::string path; + + static CpError Std(const std::error_code& error, const std::string& path) { + return {kErrno, error.value(), "cp", error.message(), path}; + } + static CpError Uv(int code, const char* syscall, const std::string& path) { + return {kUv, code, syscall, {}, path}; + } + + Local ToException(Environment* env) const { + Isolate* isolate = env->isolate(); + switch (kind) { + case kErrno: + return ErrnoException( + isolate, code, syscall, message.c_str(), path.c_str()); + case kUv: + return UVException(isolate, code, syscall, nullptr, path.c_str()); + case kEinval: + return ERR_FS_CP_EINVAL(isolate, "%s", message); + case kSymlinkToSubdirectory: + return ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY(isolate, "%s", message); + case kEexist: + return ERR_FS_CP_EEXIST(isolate, "%s", message); + // Sockets, FIFOs and unknown entries are reported to JS by kind and + // path (see CpDirJob), cpSync skips them; neither builds an error here. + case kSocket: + case kFifo: + case kUnknown: + case kNone: + break; + } + UNREACHABLE(); + } + + void Throw(Environment* env) const { + env->isolate()->ThrowException(ToException(env)); + } +}; + +CpError CopyUtimes(const std::filesystem::path& src, + const std::filesystem::path& dest) { uv_fs_t req; auto cleanup = OnScopeLeave([&req]() { uv_fs_req_cleanup(&req); }); auto src_path_str = ConvertPathToUTF8(src); int result = uv_fs_stat(nullptr, &req, src_path_str.c_str(), nullptr); if (is_uv_error(result)) { - env->ThrowUVException(result, "stat", nullptr, src_path_str.c_str()); - return false; + return CpError::Uv(result, "stat", src_path_str); } const uv_stat_t* const s = static_cast(req.ptr); @@ -3907,193 +3990,158 @@ static bool CopyUtimes(const std::filesystem::path& src, source_mtime, nullptr); if (is_uv_error(utime_result)) { - env->ThrowUVException( - utime_result, "utime", nullptr, dest_file_path_str.c_str()); - return false; + return CpError::Uv(utime_result, "utime", dest_file_path_str); } - return true; + return {}; } -static void CpSyncOverrideFile(const FunctionCallbackInfo& args) { - Environment* env = Environment::GetCurrent(args); - Isolate* isolate = env->isolate(); - - CHECK_EQ(args.Length(), 4); // src, dest, mode, preserveTimestamps - - BufferValue src(isolate, args[0]); - CHECK_NOT_NULL(*src); - ToNamespacedPath(env, &src); - - BufferValue dest(isolate, args[1]); - CHECK_NOT_NULL(*dest); - ToNamespacedPath(env, &dest); - - int mode; - if (!GetValidFileMode(env, args[2], UV_FS_COPYFILE).To(&mode)) { - return; - } - - bool preserve_timestamps = args[3]->IsTrue(); - - THROW_IF_INSUFFICIENT_PERMISSIONS( - env, permission::PermissionScope::kFileSystemRead, src.ToStringView()); - THROW_IF_INSUFFICIENT_PERMISSIONS( - env, permission::PermissionScope::kFileSystemWrite, dest.ToStringView()); - - auto src_path = src.ToPath(); - auto dest_path = dest.ToPath(); - - std::error_code error; - - if (!std::filesystem::remove(dest_path, error)) { - return env->ThrowStdErrException(error, "unlink", *dest); - } - - if (mode == 0) { - // if no mode is specified use the faster std::filesystem API - if (!std::filesystem::copy_file(src_path, dest_path, error)) { - return env->ThrowStdErrException(error, "cp", *dest); - } - } else { - uv_fs_t req; - auto cleanup = OnScopeLeave([&req]() { uv_fs_req_cleanup(&req); }); - auto result = uv_fs_copyfile(nullptr, &req, *src, *dest, mode, nullptr); - if (is_uv_error(result)) { - return env->ThrowUVException(result, "cp", nullptr, *src, *dest); - } - } +struct CpDirOptions { + bool force; + bool dereference; + bool error_on_exist; + bool verbatim_symlinks; + bool preserve_timestamps; + // Set for fs.cp(), which only takes this path for a destination that did + // not exist: nothing already present is ever opened for writing or + // followed. Directories are created with mkdir() and files with an + // exclusive uv_fs_copyfile(), so anything that appears in their place + // (a symbolic link included) is EEXIST; sockets, FIFOs and unknown + // entries are rejected as the JavaScript walk does; relative link targets + // are resolved lexically, as path.resolve() would. fs.cpSync() merges + // into existing directories, skips those entries and canonicalizes link + // targets. + bool fresh_destination; + // fs.copyFile() mode flags (COPYFILE_FICLONE etc.) for fresh_destination. + int copyfile_flags; +}; - if (preserve_timestamps) { - CopyUtimes(src_path, dest_path, env); +CpError CopyFileFresh(const std::filesystem::path& src, + const std::filesystem::path& dest, + int flags) { + uv_fs_t req; + auto cleanup = OnScopeLeave([&req]() { uv_fs_req_cleanup(&req); }); + auto src_str = ConvertPathToUTF8(src); + auto dest_str = ConvertPathToUTF8(dest); + int rc = uv_fs_copyfile(nullptr, + &req, + src_str.c_str(), + dest_str.c_str(), + flags | UV_FS_COPYFILE_EXCL, + nullptr); + if (rc < 0) { + return CpError::Uv(rc, "copyfile", dest_str); } + return {}; } -std::vector normalizePathToArray( - const std::filesystem::path& path) { - std::vector parts; - std::filesystem::path absPath = std::filesystem::absolute(path); -#ifdef _WIN32 - auto wstr = absPath.wstring(); - if (wstr.starts_with(L"\\\\?\\")) { - absPath = std::filesystem::path(wstr.substr(4)); - } -#endif - for (const auto& part : absPath) { - if (!part.empty()) parts.push_back(part.string()); +// mkdir() that does not follow or accept anything already at `path`. +CpError MakeFreshDirectory(const std::filesystem::path& path) { + uv_fs_t req; + auto cleanup = OnScopeLeave([&req]() { uv_fs_req_cleanup(&req); }); + auto path_str = ConvertPathToUTF8(path); + int rc = uv_fs_mkdir(nullptr, &req, path_str.c_str(), 0777, nullptr); + if (rc < 0) { + return CpError::Uv(rc, "mkdir", path_str); } - return parts; -} - -bool isInsideDir(const std::filesystem::path& src, - const std::filesystem::path& dest) { - auto srcArr = normalizePathToArray(src); - auto destArr = normalizePathToArray(dest); - if (srcArr.size() > destArr.size()) return false; - return std::equal(srcArr.begin(), srcArr.end(), destArr.begin()); + return {}; } -static void CpSyncCopyDir(const FunctionCallbackInfo& args) { - CHECK_EQ(args.Length(), 7); // src, dest, force, dereference, errorOnExist, - // verbatimSymlinks, preserveTimestamps - - Environment* env = Environment::GetCurrent(args); - Isolate* isolate = env->isolate(); - - BufferValue src(isolate, args[0]); - CHECK_NOT_NULL(*src); - ToNamespacedPath(env, &src); - - BufferValue dest(isolate, args[1]); - CHECK_NOT_NULL(*dest); - ToNamespacedPath(env, &dest); - - bool force = args[2]->IsTrue(); - bool dereference = args[3]->IsTrue(); - bool error_on_exist = args[4]->IsTrue(); - bool verbatim_symlinks = args[5]->IsTrue(); - bool preserve_timestamps = args[6]->IsTrue(); - - auto src_path = src.ToPath(); - auto dest_path = dest.ToPath(); - +// The recursive directory copy behind fs.cpSync() and, on the thread pool, +// fs.cp()/fsPromises.cp() when no filter function is involved. Runs on any +// thread; touches no JS. +CpError CopyDirRecursive(const std::filesystem::path& src_path, + const std::filesystem::path& dest_path, + const std::string& dest_display, + const CpDirOptions& options) { std::error_code error; - const bool dest_existed = std::filesystem::exists(dest_path, error); - std::filesystem::create_directories(dest_path, error); - if (error) { - return env->ThrowStdErrException(error, "cp", *dest); + bool dest_existed = false; + if (options.fresh_destination) { + CpError made = MakeFreshDirectory(dest_path); + if (made.kind != CpError::kNone) return made; + } else { + dest_existed = std::filesystem::exists(dest_path, error); + std::filesystem::create_directories(dest_path, error); + if (error) { + return CpError::Std(error, dest_display); + } } auto file_copy_opts = std::filesystem::copy_options::recursive; - if (force) { + if (options.force) { file_copy_opts |= std::filesystem::copy_options::overwrite_existing; - } else if (error_on_exist) { + } else if (options.error_on_exist) { file_copy_opts |= std::filesystem::copy_options::none; } else { file_copy_opts |= std::filesystem::copy_options::skip_existing; } - std::function + std::function copy_dir_contents; - copy_dir_contents = [verbatim_symlinks, - ©_dir_contents, - &env, - file_copy_opts, - preserve_timestamps, - force, - error_on_exist, - dereference, - &isolate](std::filesystem::path src, - std::filesystem::path dest) { + copy_dir_contents = [&options, ©_dir_contents, file_copy_opts]( + std::filesystem::path src, + std::filesystem::path dest) -> CpError { std::error_code error; - for (auto dir_entry : std::filesystem::directory_iterator(src)) { + // Only the error_code overloads are used from here on: this runs on a + // thread pool thread and exceptions are disabled. + auto it = std::filesystem::directory_iterator(src, error); + if (error) { + return CpError::Std(error, ConvertPathToUTF8(src)); + } + for (const auto end = std::filesystem::directory_iterator(); it != end; + it.increment(error)) { + if (error) { + return CpError::Std(error, ConvertPathToUTF8(src)); + } + const auto& dir_entry = *it; auto dest_file_path = dest / dir_entry.path().filename(); auto dest_str = ConvertPathToUTF8(dest); - if (dir_entry.is_symlink()) { - if (verbatim_symlinks) { + if (dir_entry.is_symlink(error)) { + if (options.verbatim_symlinks) { std::filesystem::copy_symlink( dir_entry.path(), dest_file_path, error); if (error) { - env->ThrowStdErrException(error, "cp", dest_str.c_str()); - return false; + return CpError::Std(error, dest_str); } } else { auto symlink_target = std::filesystem::read_symlink(dir_entry.path().c_str(), error); if (error) { - env->ThrowStdErrException(error, "cp", dest_str.c_str()); - return false; + return CpError::Std(error, dest_str); } - if (std::filesystem::exists(dest_file_path)) { - if (std::filesystem::is_symlink((dest_file_path.c_str()))) { + if (std::filesystem::exists(dest_file_path, error)) { + if (std::filesystem::is_symlink(dest_file_path, error)) { auto current_dest_symlink_target = std::filesystem::read_symlink(dest_file_path.c_str(), error); if (error) { - env->ThrowStdErrException(error, "cp", dest_str.c_str()); - return false; + return CpError::Std(error, dest_str); } - if (!dereference && - std::filesystem::is_directory(symlink_target) && + if (!options.dereference && + std::filesystem::is_directory(symlink_target, error) && isInsideDir(symlink_target, current_dest_symlink_target)) { - static constexpr const char* message = - "Cannot copy %s to a subdirectory of self %s"; - THROW_ERR_FS_CP_EINVAL( - env, message, symlink_target, current_dest_symlink_target); - return false; + return {CpError::kEinval, + 0, + "cp", + SPrintF("Cannot copy %s to a subdirectory of self %s", + symlink_target, + current_dest_symlink_target), + {}}; } // Prevent copy if src is a subdir of dest since unlinking // dest in this case would result in removing src contents // and therefore a broken symlink would be created. - if (std::filesystem::is_directory(dest_file_path) && + if (std::filesystem::is_directory(dest_file_path, error) && isInsideDir(current_dest_symlink_target, symlink_target)) { - static constexpr const char* message = - "cannot overwrite %s with %s"; - THROW_ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY( - env, message, current_dest_symlink_target, symlink_target); - return false; + return {CpError::kSymlinkToSubdirectory, + 0, + "cp", + SPrintF("cannot overwrite %s with %s", + current_dest_symlink_target, + symlink_target), + {}}; } // symlinks get overridden by cp even if force: false, this is @@ -4101,29 +4149,40 @@ static void CpSyncCopyDir(const FunctionCallbackInfo& args) { // correct? or is it a bug? std::filesystem::remove(dest_file_path, error); if (error) { - env->ThrowStdErrException(error, "cp", dest_str.c_str()); - return false; + return CpError::Std(error, dest_str); } - } else if (std::filesystem::is_regular_file(dest_file_path)) { - if (!dereference || (!force && error_on_exist)) { - auto dest_file_path_str = ConvertPathToUTF8(dest_file_path); - env->ThrowStdErrException( + } else if (std::filesystem::is_regular_file(dest_file_path, + error)) { + if (!options.dereference || + (!options.force && options.error_on_exist)) { + return CpError::Std( std::make_error_code(std::errc::file_exists), - "cp", - dest_file_path_str.c_str()); - return false; + ConvertPathToUTF8(dest_file_path)); } } } - auto symlink_target_absolute = std::filesystem::weakly_canonical( - std::filesystem::absolute(src / symlink_target)); + std::filesystem::path symlink_target_absolute; + if (options.fresh_destination) { + // As path.resolve() does: lexical only, absolute targets verbatim. + symlink_target_absolute = + symlink_target.is_absolute() + ? symlink_target + : std::filesystem::absolute(src / symlink_target, error) + .lexically_normal(); + } else { + symlink_target_absolute = std::filesystem::weakly_canonical( + std::filesystem::absolute(src / symlink_target, error), error); + } + if (error) { + return CpError::Std(error, dest_str); + } #ifdef _WIN32 auto wstr = symlink_target_absolute.wstring(); if (wstr.starts_with(L"\\\\?\\")) { symlink_target_absolute = std::filesystem::path(wstr.substr(4)); } #endif - if (dir_entry.is_directory()) { + if (dir_entry.is_directory(error)) { std::filesystem::create_directory_symlink( symlink_target_absolute, dest_file_path, error); } else { @@ -4131,67 +4190,281 @@ static void CpSyncCopyDir(const FunctionCallbackInfo& args) { symlink_target_absolute, dest_file_path, error); } if (error) { - env->ThrowStdErrException(error, "cp", dest_str.c_str()); - return false; + return CpError::Std(error, dest_str); } } - } else if (dir_entry.is_directory()) { + } else if (dir_entry.is_directory(error)) { auto entry_dir_path = src / dir_entry.path().filename(); - const bool created = - std::filesystem::create_directory(dest_file_path, error); - if (error) { - env->ThrowStdErrException( - error, "cp", ConvertPathToUTF8(dest_file_path).c_str()); - return false; + bool created = true; + if (options.fresh_destination) { + CpError made = MakeFreshDirectory(dest_file_path); + if (made.kind != CpError::kNone) return made; + } else { + created = std::filesystem::create_directory(dest_file_path, error); + if (error) { + return CpError::Std(error, ConvertPathToUTF8(dest_file_path)); + } } - auto success = copy_dir_contents(entry_dir_path, dest_file_path); - if (!success) { - return false; + CpError inner = copy_dir_contents(entry_dir_path, dest_file_path); + if (inner.kind != CpError::kNone) { + return inner; } // A directory created by the copy gets the mode of its source once // its contents are in (the source may be read-only). if (created) { std::filesystem::permissions( - dest_file_path, dir_entry.status().permissions(), error); + dest_file_path, dir_entry.status(error).permissions(), error); if (error) { - env->ThrowStdErrException( - error, "cp", ConvertPathToUTF8(dest_file_path).c_str()); - return false; + return CpError::Std(error, ConvertPathToUTF8(dest_file_path)); } } - } else if (dir_entry.is_regular_file()) { - std::filesystem::copy_file( - dir_entry.path(), dest_file_path, file_copy_opts, error); + } else if (dir_entry.is_regular_file(error)) { + if (options.fresh_destination) { + CpError copied = CopyFileFresh( + dir_entry.path(), dest_file_path, options.copyfile_flags); + if (copied.kind != CpError::kNone) return copied; + } else { + std::filesystem::copy_file( + dir_entry.path(), dest_file_path, file_copy_opts, error); + } if (error) { if (error == std::errc::file_exists) { - THROW_ERR_FS_CP_EEXIST(isolate, - "[ERR_FS_CP_EEXIST]: Target already exists: " - "cp returned EEXIST (%s already exists)", - dest_file_path); - return false; + return {CpError::kEexist, + 0, + "cp", + SPrintF("[ERR_FS_CP_EEXIST]: Target already exists: " + "cp returned EEXIST (%s already exists)", + dest_file_path), + {}}; } - env->ThrowStdErrException(error, "cp", dest_str.c_str()); - return false; + return CpError::Std(error, dest_str); } - if (preserve_timestamps && - !CopyUtimes(dir_entry.path(), dest_file_path, env)) { - return false; + if (options.preserve_timestamps) { + CpError utimes = CopyUtimes(dir_entry.path(), dest_file_path); + if (utimes.kind != CpError::kNone) { + return utimes; + } } + } else if (options.fresh_destination) { + CpError::Kind kind = dir_entry.is_socket(error) ? CpError::kSocket + : dir_entry.is_fifo(error) ? CpError::kFifo + : CpError::kUnknown; + return {kind, UV_EINVAL, "cp", {}, ConvertPathToUTF8(dest_file_path)}; } } - return true; + return {}; }; - if (copy_dir_contents(src_path, dest_path) && !dest_existed) { + CpError result = copy_dir_contents(src_path, dest_path); + if (result.kind == CpError::kNone && !dest_existed) { std::filesystem::permissions( - dest_path, std::filesystem::status(src_path).permissions(), error); + dest_path, + std::filesystem::status(src_path, error).permissions(), + error); if (error) { + return CpError::Std(error, dest_display); + } + } + return result; +} + +} // namespace + +static void CpSyncOverrideFile(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + Isolate* isolate = env->isolate(); + + CHECK_EQ(args.Length(), 4); // src, dest, mode, preserveTimestamps + + BufferValue src(isolate, args[0]); + CHECK_NOT_NULL(*src); + ToNamespacedPath(env, &src); + + BufferValue dest(isolate, args[1]); + CHECK_NOT_NULL(*dest); + ToNamespacedPath(env, &dest); + + int mode; + if (!GetValidFileMode(env, args[2], UV_FS_COPYFILE).To(&mode)) { + return; + } + + bool preserve_timestamps = args[3]->IsTrue(); + + THROW_IF_INSUFFICIENT_PERMISSIONS( + env, permission::PermissionScope::kFileSystemRead, src.ToStringView()); + THROW_IF_INSUFFICIENT_PERMISSIONS( + env, permission::PermissionScope::kFileSystemWrite, dest.ToStringView()); + + auto src_path = src.ToPath(); + auto dest_path = dest.ToPath(); + + std::error_code error; + + if (!std::filesystem::remove(dest_path, error)) { + return env->ThrowStdErrException(error, "unlink", *dest); + } + + if (mode == 0) { + // if no mode is specified use the faster std::filesystem API + if (!std::filesystem::copy_file(src_path, dest_path, error)) { return env->ThrowStdErrException(error, "cp", *dest); } + } else { + uv_fs_t req; + auto cleanup = OnScopeLeave([&req]() { uv_fs_req_cleanup(&req); }); + auto result = uv_fs_copyfile(nullptr, &req, *src, *dest, mode, nullptr); + if (is_uv_error(result)) { + return env->ThrowUVException(result, "cp", nullptr, *src, *dest); + } + } + + if (preserve_timestamps) { + CpError error = CopyUtimes(src_path, dest_path); + if (error.kind != CpError::kNone) { + error.Throw(env); + } } } +static void CpSyncCopyDir(const FunctionCallbackInfo& args) { + CHECK_EQ(args.Length(), 7); // src, dest, force, dereference, errorOnExist, + // verbatimSymlinks, preserveTimestamps + + Environment* env = Environment::GetCurrent(args); + Isolate* isolate = env->isolate(); + + BufferValue src(isolate, args[0]); + CHECK_NOT_NULL(*src); + ToNamespacedPath(env, &src); + + BufferValue dest(isolate, args[1]); + CHECK_NOT_NULL(*dest); + ToNamespacedPath(env, &dest); + + bool force = args[2]->IsTrue(); + bool dereference = args[3]->IsTrue(); + bool error_on_exist = args[4]->IsTrue(); + bool verbatim_symlinks = args[5]->IsTrue(); + bool preserve_timestamps = args[6]->IsTrue(); + + auto src_path = src.ToPath(); + auto dest_path = dest.ToPath(); + + CpError error = CopyDirRecursive(src_path, + dest_path, + dest.ToString(), + {force, + dereference, + error_on_exist, + verbatim_symlinks, + preserve_timestamps, + false, + 0}); + if (error.kind != CpError::kNone) { + error.Throw(env); + } +} + +// JS: const job = new CpDirJob(src, dest, force, dereference, errorOnExist, +// verbatimSymlinks, preserveTimestamps); +// job.ondone = (err) => {...}; job.run(); +// Runs CopyDirRecursive() on the thread pool for fs.cp()/fsPromises.cp(). +class CpDirJob final : public AsyncWrap, public ThreadPoolWork { + public: + static void New(const FunctionCallbackInfo& args) { + CHECK(args.IsConstructCall()); + Environment* env = Environment::GetCurrent(args); + CHECK_EQ(args.Length(), 8); + CHECK(args[7]->IsInt32()); + BufferValue src(env->isolate(), args[0]); + CHECK_NOT_NULL(*src); + ToNamespacedPath(env, &src); + BufferValue dest(env->isolate(), args[1]); + CHECK_NOT_NULL(*dest); + ToNamespacedPath(env, &dest); + new CpDirJob(env, + args.This(), + src.ToPath(), + dest.ToPath(), + dest.ToString(), + {args[2]->IsTrue(), + args[3]->IsTrue(), + args[4]->IsTrue(), + args[5]->IsTrue(), + args[6]->IsTrue(), + true, + args[7].As()->Value()}); + } + + static void Run(const FunctionCallbackInfo& args) { + CpDirJob* job; + ASSIGN_OR_RETURN_UNWRAP(&job, args.This()); + CHECK(!job->scheduled_); + job->scheduled_ = true; + job->ClearWeak(); + job->ScheduleWork(); + } + + void DoThreadPoolWork() override { + error_ = CopyDirRecursive(src_, dest_, dest_display_, options_); + } + + void AfterThreadPoolWork(int status) override { + Environment* env = AsyncWrap::env(); + std::unique_ptr self(this); + CHECK(status == 0 || status == UV_ECANCELED); + if (status == UV_ECANCELED || !env->can_call_into_js()) return; + Isolate* isolate = env->isolate(); + HandleScope handle_scope(isolate); + Context::Scope context_scope(env->context()); + Local argv[] = { + Null(isolate), Undefined(isolate), Undefined(isolate)}; + const char* special = error_.kind == CpError::kSocket ? "socket" + : error_.kind == CpError::kFifo ? "fifo" + : error_.kind == CpError::kUnknown ? "unknown" + : nullptr; + if (special != nullptr) { + Local path; + if (!ToV8Value(env->context(), error_.path).ToLocal(&path)) return; + argv[1] = OneByteString(isolate, special); + argv[2] = path; + } else if (error_.kind != CpError::kNone) { + argv[0] = error_.ToException(env); + } + MakeCallback(env->ondone_string(), arraysize(argv), argv); + } + + bool IsNotIndicativeOfMemoryLeakAtExit() const override { return true; } + SET_NO_MEMORY_INFO() + SET_MEMORY_INFO_NAME(CpDirJob) + SET_SELF_SIZE(CpDirJob) + + private: + CpDirJob(Environment* env, + Local object, + std::filesystem::path&& src, + std::filesystem::path&& dest, + std::string&& dest_display, + CpDirOptions options) + : AsyncWrap(env, object, AsyncWrap::PROVIDER_FSREQCALLBACK), + ThreadPoolWork(env, "fs.cp"), + src_(std::move(src)), + dest_(std::move(dest)), + dest_display_(std::move(dest_display)), + options_(options) { + MakeWeak(); + } + + const std::filesystem::path src_; + const std::filesystem::path dest_; + const std::string dest_display_; + const CpDirOptions options_; + CpError error_; + bool scheduled_ = false; +}; + BindingData::FilePathIsFileReturnType BindingData::FilePathIsFile( Environment* env, const std::string& file_path) { THROW_IF_INSUFFICIENT_PERMISSIONS( @@ -4561,6 +4834,12 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data, SetMethod(isolate, target, "cpSyncOverrideFile", CpSyncOverrideFile); SetMethod(isolate, target, "cpSyncCopyDir", CpSyncCopyDir); + Local cpj = NewFunctionTemplate(isolate, CpDirJob::New); + cpj->InstanceTemplate()->SetInternalFieldCount(CpDirJob::kInternalFieldCount); + cpj->Inherit(AsyncWrap::GetConstructorTemplate(isolate_data)); + SetProtoMethod(isolate, cpj, "run", CpDirJob::Run); + SetConstructorFunction(isolate, target, "CpDirJob", cpj); + StatWatcher::CreatePerIsolateProperties(isolate_data, target); BindingData::CreatePerIsolateProperties(isolate_data, target); @@ -4681,6 +4960,8 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(CpSyncCheckPaths); registry->Register(CpSyncOverrideFile); registry->Register(CpSyncCopyDir); + registry->Register(CpDirJob::New); + registry->Register(CpDirJob::Run); registry->Register(Chmod); registry->Register(FChmod); diff --git a/test/parallel/test-fs-cp-async-destination-appears-late.mjs b/test/parallel/test-fs-cp-async-destination-appears-late.mjs new file mode 100644 index 00000000000..40ec15e1ef3 --- /dev/null +++ b/test/parallel/test-fs-cp-async-destination-appears-late.mjs @@ -0,0 +1,35 @@ +// This tests that cp() into a destination that did not exist when it was +// checked, but does by the time the copy starts, fails with EEXIST instead +// of copying through whatever appeared there. +import '../common/index.mjs'; +import { nextdir } from '../common/fs.js'; +import assert from 'node:assert'; +import { createHook } from 'node:async_hooks'; +import { existsSync, lstatSync, mkdirSync, symlinkSync, writeFileSync, promises } from 'node:fs'; +import { join } from 'node:path'; +import tmpdir from '../common/tmpdir.js'; + +tmpdir.refresh(); +const src = nextdir(); +const dest = nextdir(); +const target = nextdir(); +mkdirSync(src); +mkdirSync(target); +writeFileSync(join(src, 'file'), 'x'); + +let injected = false; +const hook = createHook({ + init(id, type) { + if (!injected && type === 'FSREQCALLBACK' && !existsSync(dest)) { + injected = true; + symlinkSync(target, dest, 'dir'); + } + }, +}).enable(); +const outcome = await promises.cp(src, dest, { recursive: true }).then(() => null, (err) => err); +hook.disable(); +assert.ok(injected, 'the hook found no request to inject the symbolic link at'); +assert.strictEqual(outcome?.code, 'EEXIST'); +assert.strictEqual(outcome?.syscall, 'mkdir'); +assert.ok(lstatSync(dest).isSymbolicLink()); +assert.ok(!existsSync(join(target, 'file'))); diff --git a/test/parallel/test-fs-cp-async-special-files-in-tree.mjs b/test/parallel/test-fs-cp-async-special-files-in-tree.mjs new file mode 100644 index 00000000000..9a028f297ca --- /dev/null +++ b/test/parallel/test-fs-cp-async-special-files-in-tree.mjs @@ -0,0 +1,53 @@ +// This tests that cp() rejects a socket or a FIFO found inside the copied +// tree with the same errors as for a top-level one, while cpSync() skips them. + +import * as common from '../common/index.mjs'; +import assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, writeFileSync, promises } from 'node:fs'; +import { createServer } from 'node:net'; +import { join } from 'node:path'; +import { nextdir } from '../common/fs.js'; +import tmpdir from '../common/tmpdir.js'; + +if (common.isWindows) + common.skip('No socket/FIFO support on Windows'); +if (common.isInsideDirWithUnusualChars) + common.skip('Test is broken in directories with unusual characters'); + +tmpdir.refresh(); + +{ + const src = nextdir(); + mkdirSync(join(src, 'd'), { recursive: true }); + writeFileSync(join(src, 'd', 'file'), 'x'); + const server = createServer(); + // The socket path can exceed the platform limit in a deep tmpdir; skip then. + const listening = await new Promise((resolve) => { + server.on('error', () => resolve(false)); + server.listen(join(src, 'd', 's.sock'), () => resolve(true)); + }); + if (!listening) { + common.printSkipMessage('socket path too long'); + } else { + await assert.rejects(promises.cp(src, nextdir(), { recursive: true }), { code: 'ERR_FS_CP_SOCKET' }); + const dest = nextdir(); + cpSync(src, dest, { recursive: true }); + assert.ok(existsSync(join(dest, 'd', 'file'))); + server.close(); + } +} + +{ + const src = nextdir(); + mkdirSync(join(src, 'dir'), { recursive: true }); + writeFileSync(join(src, 'dir', 'file'), 'x'); + if (spawnSync('mkfifo', [join(src, 'dir', 'fifo')]).status !== 0) { + common.printSkipMessage('mkfifo not available'); + } else { + await assert.rejects(promises.cp(src, nextdir(), { recursive: true }), { code: 'ERR_FS_CP_FIFO_PIPE' }); + const dest = nextdir(); + cpSync(src, dest, { recursive: true }); + assert.ok(existsSync(join(dest, 'dir', 'file'))); + } +} diff --git a/test/parallel/test-fs-cp-async-symlink-targets.mjs b/test/parallel/test-fs-cp-async-symlink-targets.mjs new file mode 100644 index 00000000000..81e861c9085 --- /dev/null +++ b/test/parallel/test-fs-cp-async-symlink-targets.mjs @@ -0,0 +1,29 @@ +// This tests that cp() into a new destination writes the same link targets +// as path.resolve() of the original ones: relative targets made absolute +// lexically (intermediate links kept), absolute targets left as they are. +import { isWindows, skip } from '../common/index.mjs'; +import { nextdir } from '../common/fs.js'; +import assert from 'node:assert'; +import { mkdirSync, readlinkSync, symlinkSync, writeFileSync, promises } from 'node:fs'; +import { join, resolve } from 'node:path'; +import tmpdir from '../common/tmpdir.js'; + +if (isWindows) + skip('symbolic links need elevated privileges on Windows'); + +tmpdir.refresh(); +const src = nextdir(); +mkdirSync(join(src, 'real'), { recursive: true }); +writeFileSync(join(src, 'real', 'file'), 'data'); +symlinkSync('real', join(src, 'alias')); +symlinkSync('alias/file', join(src, 'link')); +const absoluteTarget = join(tmpdir.path, 'x', '..', 'elsewhere'); +symlinkSync(absoluteTarget, join(src, 'abs')); + +for (const filter of [undefined, () => true]) { + const dest = nextdir(); + await promises.cp(src, dest, { recursive: true, filter }); + assert.strictEqual(readlinkSync(join(dest, 'link')), resolve(src, 'alias/file')); + assert.strictEqual(readlinkSync(join(dest, 'alias')), resolve(src, 'real')); + assert.strictEqual(readlinkSync(join(dest, 'abs')), absoluteTarget); +} diff --git a/test/parallel/test-fs-cp-async-with-mode-flags.mjs b/test/parallel/test-fs-cp-async-with-mode-flags.mjs index 99f6b5fe09d..6e10d5859ef 100644 --- a/test/parallel/test-fs-cp-async-with-mode-flags.mjs +++ b/test/parallel/test-fs-cp-async-with-mode-flags.mjs @@ -29,3 +29,14 @@ cp(src, dest, mustNotMutateObjectDeep({ assert(err.code === 'ENOTSUP' || err.code === 'ENOTTY' || err.code === 'ENOSYS' || err.code === 'EXDEV'); })); + +// The mode flags reach copyFile() whether or not a filter is given. +{ + const { promises } = await import('node:fs'); + const outcome = (filter) => promises.cp(src, nextdir(), { + recursive: true, + mode: constants.COPYFILE_FICLONE_FORCE, + filter, + }).then(() => 'copied', (err) => `${err.code} ${err.syscall}`); + assert.strictEqual(await outcome(undefined), await outcome(() => true)); +} diff --git a/test/parallel/test-fs-cp-unreadable-directory.mjs b/test/parallel/test-fs-cp-unreadable-directory.mjs new file mode 100644 index 00000000000..c6343e6b277 --- /dev/null +++ b/test/parallel/test-fs-cp-unreadable-directory.mjs @@ -0,0 +1,33 @@ +// This tests that cp() and cpSync() report an unreadable directory inside the +// source tree as an error instead of terminating the process. +import { isWindows, skip } from '../common/index.mjs'; +import { nextdir } from '../common/fs.js'; +import assert from 'node:assert'; +import { chmodSync, cpSync, mkdirSync, readdirSync, writeFileSync, promises } from 'node:fs'; +import { join } from 'node:path'; +import tmpdir from '../common/tmpdir.js'; + +if (isWindows) + skip('no way to make a directory unreadable'); +if (process.getuid() === 0) + skip('root can read the directory anyway'); + +tmpdir.refresh(); +const src = nextdir(); +mkdirSync(join(src, 'locked'), { recursive: true }); +writeFileSync(join(src, 'file'), 'x'); +chmodSync(join(src, 'locked'), 0o000); +try { + readdirSync(join(src, 'locked')); + chmodSync(join(src, 'locked'), 0o700); + skip('the directory is still readable'); +} catch { + // Expected: it is unreadable. +} + +try { + assert.throws(() => cpSync(src, nextdir(), { recursive: true }), { code: 'EACCES' }); + await assert.rejects(promises.cp(src, nextdir(), { recursive: true }), { code: 'EACCES' }); +} finally { + chmodSync(join(src, 'locked'), 0o700); +}