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 6a197c9b794096f35f4b30c2339d98b4f19d1350 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. Sockets, FIFOs and unknown entries found by the job are reported back to JavaScript, which rejects them with the same SystemErrors as before (cpSync keeps skipping them). The same tree now takes ~28 ms with under 1 ms on the main thread. 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' as the syscall as cpSync reports them. Signed-off-by: Shelley Vohr --- benchmark/fs/bench-cp.js | 33 + lib/internal/fs/cp/cp.js | 68 ++- src/node_file.cc | 572 ++++++++++++------ ...test-fs-cp-async-special-files-in-tree.mjs | 53 ++ .../test-fs-cp-unreadable-directory.mjs | 33 + 5 files changed, 555 insertions(+), 204 deletions(-) create mode 100644 benchmark/fs/bench-cp.js create mode 100644 test/parallel/test-fs-cp-async-special-files-in-tree.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..7f9d5622345 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); + // 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..4dd3fd7ef32 100644 --- a/src/node_file.cc +++ b/src/node_file.cc @@ -3882,96 +3882,12 @@ static void CpSyncCheckPaths(const FunctionCallbackInfo& args) { } } -static bool CopyUtimes(const std::filesystem::path& src, - const std::filesystem::path& dest, - Environment* env) { - 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; - } - - const uv_stat_t* const s = static_cast(req.ptr); - const double source_atime = s->st_atim.tv_sec + s->st_atim.tv_nsec / 1e9; - const double source_mtime = s->st_mtim.tv_sec + s->st_mtim.tv_nsec / 1e9; - - auto dest_file_path_str = ConvertPathToUTF8(dest); - int utime_result = uv_fs_utime(nullptr, - &req, - dest_file_path_str.c_str(), - source_atime, - source_mtime, - nullptr); - if (is_uv_error(utime_result)) { - env->ThrowUVException( - utime_result, "utime", nullptr, dest_file_path_str.c_str()); - return false; - } - return true; -} - -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) { - CopyUtimes(src_path, dest_path, env); - } -} - std::vector normalizePathToArray( const std::filesystem::path& path) { std::vector parts; - std::filesystem::path absPath = std::filesystem::absolute(path); + 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"\\\\?\\")) { @@ -3992,108 +3908,193 @@ bool isInsideDir(const std::filesystem::path& src, return std::equal(srcArr.begin(), srcArr.end(), destArr.begin()); } -static void CpSyncCopyDir(const FunctionCallbackInfo& args) { - CHECK_EQ(args.Length(), 7); // src, dest, force, dereference, errorOnExist, - // verbatimSymlinks, preserveTimestamps +namespace { - Environment* env = Environment::GetCurrent(args); - Isolate* isolate = env->isolate(); +// 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; - BufferValue src(isolate, args[0]); - CHECK_NOT_NULL(*src); - ToNamespacedPath(env, &src); + 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}; + } - BufferValue dest(isolate, args[1]); - CHECK_NOT_NULL(*dest); - ToNamespacedPath(env, &dest); + 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(); + } - 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(); + void Throw(Environment* env) const { + env->isolate()->ThrowException(ToException(env)); + } +}; - auto src_path = src.ToPath(); - auto dest_path = dest.ToPath(); +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)) { + return CpError::Uv(result, "stat", src_path_str); + } + const uv_stat_t* const s = static_cast(req.ptr); + const double source_atime = s->st_atim.tv_sec + s->st_atim.tv_nsec / 1e9; + const double source_mtime = s->st_mtim.tv_sec + s->st_mtim.tv_nsec / 1e9; + + auto dest_file_path_str = ConvertPathToUTF8(dest); + int utime_result = uv_fs_utime(nullptr, + &req, + dest_file_path_str.c_str(), + source_atime, + source_mtime, + nullptr); + if (is_uv_error(utime_result)) { + return CpError::Uv(utime_result, "utime", dest_file_path_str); + } + return {}; +} + +struct CpDirOptions { + bool force; + bool dereference; + bool error_on_exist; + bool verbatim_symlinks; + bool preserve_timestamps; + // fs.cp() rejects sockets, FIFOs and unknown entries; fs.cpSync() skips them. + bool reject_special_files; +}; + +// 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); + 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 +4102,30 @@ 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::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 +4133,267 @@ 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; + 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()) { + } else if (dir_entry.is_regular_file(error)) { 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.reject_special_files) { + 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}); + 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(), 7); + 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}); + } + + 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 +4763,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 +4889,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-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-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); +}