fs: honor dereference for symlinks nested in cpSync trees - #65731
fs: honor dereference for symlinks nested in cpSync trees#65731christianaurichzm wants to merge 1 commit into
Conversation
86f460c to
346ed1c
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #65731 +/- ##
==========================================
+ Coverage 90.16% 90.17% +0.01%
==========================================
Files 771 771
Lines 265445 265509 +64
Branches 50455 50482 +27
==========================================
+ Hits 239329 239425 +96
+ Misses 17056 17012 -44
- Partials 9060 9072 +12
🚀 New features to boost your workflow:
|
|
@codebytere, would you mind taking a look when you have a chance? Thanks! |
codebytere
left a comment
There was a problem hiding this comment.
LGTM - ran the new test and checked parity with the JS walker locally (dest as dir / file / link under force, force: false, errorOnExist), all matches. couple of non-blocking notes inline.
| if (is_symlink && dereference) { | ||
| // Mirror the JavaScript walk: create the destination only when it | ||
| // does not exist, otherwise recurse into the existing path. | ||
| std::error_code dest_error; | ||
| const bool dest_exists = | ||
| std::filesystem::exists(dest_file_path, dest_error); | ||
| if (dest_error) { | ||
| env->ThrowStdErrException(dest_error, "cp", dest_str.c_str()); | ||
| return false; | ||
| } | ||
| if (!dest_exists) { | ||
| std::filesystem::create_directory(dest_file_path, dest_error); | ||
| if (dest_error) { | ||
| env->ThrowStdErrException(dest_error, "cp", dest_str.c_str()); | ||
| return false; | ||
| } | ||
| } | ||
| } else { | ||
| std::filesystem::create_directory(dest_file_path); | ||
| } |
There was a problem hiding this comment.
(Non-blocking) std::filesystem::create_directory(dest_file_path, ec) already treats an existing directory (or link to one) as success and reports EEXIST for anything else, so i think this whole block can be that one call plus the if (ec) throw, and the unchecked create_directory() in the else arm could take the same form while we're here - that one still goes through the throwing overload.
There was a problem hiding this comment.
I tried simplifying this to std::filesystem::create_directory(dest_file_path, dest_error), but it changes the existing destination-file case from ENOTDIR to EEXIST, while the JS walker returns ENOTDIR. So I kept the current branch to preserve parity. Thanks for the suggestion!
| if (is_symlink && dereference) { | ||
| // Only a dereferenced link reaches this branch as a link, so what an | ||
| // occupied destination means here is settled the way the JavaScript | ||
| // walk settles it: replaced under force, left untouched otherwise. | ||
| // Replacing an existing destination unlinks the entry first, which is | ||
| // what keeps an existing link there from being written through. | ||
| std::error_code dest_error; | ||
| const bool dest_exists = | ||
| std::filesystem::exists(dest_file_path, dest_error); | ||
| if (dest_error) { | ||
| env->ThrowStdErrException(dest_error, "cp", dest_str.c_str()); | ||
| return false; | ||
| } | ||
|
|
||
| if (dest_exists) { | ||
| if (!force) { | ||
| if (error_on_exist) { | ||
| THROW_ERR_FS_CP_EEXIST( | ||
| isolate, | ||
| "[ERR_FS_CP_EEXIST]: Target already exists: " | ||
| "cp returned EEXIST (%s already exists)", | ||
| dest_file_path); | ||
| return false; | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| std::filesystem::remove(dest_file_path, dest_error); | ||
| if (dest_error) { | ||
| env->ThrowStdErrException(dest_error, "cp", dest_str.c_str()); | ||
| return false; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
(Non-blocking) the !force half of this duplicates what file_copy_opts already does (skip_existing / the file_exists -> ERR_FS_CP_EEXIST mapping below), so the part that's new is the remove() under force. That one matters beyond dereferenced links though: a plain file copied over an existing dest symlink is written through the link by copy_file(overwrite_existing) today, where the JS walker unlinks and replaces it. i'd either do the remove for every regular-file copy under force or leave it out here and we fix the write-through separately.
There was a problem hiding this comment.
Removing the !force branch changes the destination-directory case: std::filesystem::copy_file() returns EINVAL, while the JS walker leaves the destination directory untouched. The guard is still needed for parity here.
When no filter is given, cpSync copies directory contents in C++. That loop recreated every symlink it found, consulting dereference only for the subdirectory-of-self guards, so a symlink nested in the tree was copied as a link even with dereference set. Only a symlink passed as src was dereferenced, because that one is resolved by stat() in JavaScript before the C++ copy starts. The directory and regular file branches already follow symlinks, so links that resolve to those types can fall through to them. A link whose target cannot be reached has nothing to copy, and uv_fs_stat() reports the underlying filesystem error. When following a link, preserve the existing force and errorOnExist behavior for occupied destinations. Under force, remove an existing destination first so copying does not write through a destination symlink. Signed-off-by: Christian Aurich <christian.aurichzm@gmail.com>
346ed1c to
d7ae5a4
Compare
|
Rebased onto current The regression test still fails on Re-requesting review since the implementation changed during the rebase. If this looks good, could someone also add |
|
@christianaurichzm ... thanks for the ping. generally LGTM but I'm going to take another read through before signing off. |
fs.cpSync(src, dest, { dereference: true, recursive: true })copies symlinks found inside the tree as symlinks instead of copying their targets.Reported in #59168 as a regression in 22.17 and still reproducible on
main:Cause
Without a filter,
cp-sync.jshands the directory tree to the nativeCopyDirRecursive()walker. Nested symlinks were always handled as symlinks there, even whendereferencewas enabled.A symlink passed directly as
srcis unaffected because it is resolved before the native directory walk begins.Fix
With
dereferenceenabled, nested symlinks that resolve to directories or regular files now use the corresponding native copy paths.Unreachable targets are checked through
uv_fs_stat()so filesystem errors are reported correctly across platforms.Existing destinations keep the JavaScript walker's
forceanderrorOnExistbehavior.This version is rebased onto the shared
CopyDirRecursive()walker introduced by #65488. The asyncfs.cp()native path remains unchanged.Fixes: #59168