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..e93d8e81ae3e 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,42 @@ 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, copyFile() modifiers + // are requested, 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 && opts.mode === 0 && !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..3ab32fc956ea 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,13 +3990,258 @@ 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 {}; +} + +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, and follows the JavaScript walk's rules there: every + // destination directory is created with mkdir() and anything already in + // its place (a symbolic link included) is EEXIST; sockets, FIFOs and + // unknown entries are rejected; 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; +}; + +// 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 {}; } +// 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; + 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 (options.force) { + file_copy_opts |= std::filesystem::copy_options::overwrite_existing; + } 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 + copy_dir_contents; + copy_dir_contents = [&options, ©_dir_contents, file_copy_opts]( + std::filesystem::path src, + std::filesystem::path dest) -> CpError { + std::error_code error; + // 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(error)) { + if (options.verbatim_symlinks) { + std::filesystem::copy_symlink( + dir_entry.path(), dest_file_path, error); + if (error) { + return CpError::Std(error, dest_str); + } + } else { + auto symlink_target = + std::filesystem::read_symlink(dir_entry.path().c_str(), error); + if (error) { + return CpError::Std(error, dest_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) { + return CpError::Std(error, dest_str); + } + + if (!options.dereference && + std::filesystem::is_directory(symlink_target, error) && + isInsideDir(symlink_target, current_dest_symlink_target)) { + 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, error) && + isInsideDir(current_dest_symlink_target, symlink_target)) { + 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 + // being applied here for backward compatibility, but is it + // correct? or is it a bug? + std::filesystem::remove(dest_file_path, error); + if (error) { + return CpError::Std(error, dest_str); + } + } 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), + ConvertPathToUTF8(dest_file_path)); + } + } + } + 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(error)) { + std::filesystem::create_directory_symlink( + symlink_target_absolute, dest_file_path, error); + } else { + std::filesystem::create_symlink( + symlink_target_absolute, dest_file_path, error); + } + if (error) { + return CpError::Std(error, dest_str); + } + } + } else if (dir_entry.is_directory(error)) { + auto entry_dir_path = src / dir_entry.path().filename(); + 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)); + } + } + 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(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) { + return {CpError::kEexist, + 0, + "cp", + SPrintF("[ERR_FS_CP_EEXIST]: Target already exists: " + "cp returned EEXIST (%s already exists)", + dest_file_path), + {}}; + } + return CpError::Std(error, dest_str); + } + + 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 {}; + }; + + 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(); @@ -3964,32 +4292,11 @@ static void CpSyncOverrideFile(const FunctionCallbackInfo& args) { } 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); -#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()); + CpError error = CopyUtimes(src_path, dest_path); + if (error.kind != CpError::kNone) { + error.Throw(env); + } } - 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()); } static void CpSyncCopyDir(const FunctionCallbackInfo& args) { @@ -4016,157 +4323,115 @@ static void CpSyncCopyDir(const FunctionCallbackInfo& args) { auto src_path = src.ToPath(); auto dest_path = dest.ToPath(); - std::error_code error; - std::filesystem::create_directories(dest_path, error); - if (error) { - return env->ThrowStdErrException(error, "cp", *dest); + 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); } +} - auto file_copy_opts = std::filesystem::copy_options::recursive; - if (force) { - file_copy_opts |= std::filesystem::copy_options::overwrite_existing; - } else if (error_on_exist) { - file_copy_opts |= std::filesystem::copy_options::none; - } else { - file_copy_opts |= std::filesystem::copy_options::skip_existing; +// 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}); } - 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) { - std::error_code error; - for (auto dir_entry : std::filesystem::directory_iterator(src)) { - auto dest_file_path = dest / dir_entry.path().filename(); - auto dest_str = ConvertPathToUTF8(dest); - - if (dir_entry.is_symlink()) { - if (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; - } - } 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; - } - - if (std::filesystem::exists(dest_file_path)) { - if (std::filesystem::is_symlink((dest_file_path.c_str()))) { - 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; - } + 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(); + } - if (!dereference && - std::filesystem::is_directory(symlink_target) && - 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; - } + void DoThreadPoolWork() override { + error_ = CopyDirRecursive(src_, dest_, dest_display_, options_); + } - // 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) && - 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; - } + 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); + } - // symlinks get overridden by cp even if force: false, this is - // being applied here for backward compatibility, but is it - // 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; - } - } 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( - std::make_error_code(std::errc::file_exists), - "cp", - dest_file_path_str.c_str()); - return false; - } - } - } - auto symlink_target_absolute = std::filesystem::weakly_canonical( - std::filesystem::absolute(src / symlink_target)); -#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()) { - std::filesystem::create_directory_symlink( - symlink_target_absolute, dest_file_path, error); - } else { - std::filesystem::create_symlink( - symlink_target_absolute, dest_file_path, error); - } - if (error) { - env->ThrowStdErrException(error, "cp", dest_str.c_str()); - return false; - } - } - } else if (dir_entry.is_directory()) { - 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; - } - } else if (dir_entry.is_regular_file()) { - 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; - } - env->ThrowStdErrException(error, "cp", dest_str.c_str()); - return false; - } + bool IsNotIndicativeOfMemoryLeakAtExit() const override { return true; } + SET_NO_MEMORY_INFO() + SET_MEMORY_INFO_NAME(CpDirJob) + SET_SELF_SIZE(CpDirJob) - if (preserve_timestamps && - !CopyUtimes(dir_entry.path(), dest_file_path, env)) { - return false; - } - } - } - return true; - }; + 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(); + } - copy_dir_contents(src_path, dest_path); -} + 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) { @@ -4537,6 +4802,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 +4928,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 000000000000..40ec15e1ef39 --- /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 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-async-symlink-targets.mjs b/test/parallel/test-fs-cp-async-symlink-targets.mjs new file mode 100644 index 000000000000..81e861c90859 --- /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 99f6b5fe09d9..6e10d5859ef1 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-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); +}