diff --git a/benchmark/fs/bench-cp.js b/benchmark/fs/bench-cp.js new file mode 100644 index 000000000000..ffaeb87705f5 --- /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 10c52b114634..7f9d56223453 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 871d8f16bd34..4dd3fd7ef323 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,107 +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 @@ -4100,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 { @@ -4130,44 +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(); - std::filesystem::create_directory(dest_file_path); - auto success = copy_dir_contents(entry_dir_path, dest_file_path); - if (!success) { - return false; + const bool created = + std::filesystem::create_directory(dest_file_path, error); + if (error) { + return CpError::Std(error, ConvertPathToUTF8(dest_file_path)); + } + CpError inner = copy_dir_contents(entry_dir_path, dest_file_path); + if (inner.kind != CpError::kNone) { + return inner; } - } else if (dir_entry.is_regular_file()) { + // 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(error).permissions(), error); + if (error) { + return CpError::Std(error, ConvertPathToUTF8(dest_file_path)); + } + } + } 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 {}; }; - copy_dir_contents(src_path, dest_path); + 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, 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( @@ -4537,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); @@ -4657,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 000000000000..9a028f297caa --- /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-sync-directory-mode.mjs b/test/parallel/test-fs-cp-sync-directory-mode.mjs new file mode 100644 index 000000000000..a6c54d48e864 --- /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); 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 000000000000..c6343e6b277e --- /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); +}