Skip to content

Commit 25cbae4

Browse files
committed
fs: fix repeated copy of directory with symlinks
Repeatedly copying a directory that contains a symlink to an unrelated directory fails on the second copy with ERR_FS_CP_EINVAL because the symlink target is mistaken for a self-referential copy. Compare symlink targets using canonicalized paths, treating identical targets as self-copies only when the target is the destination root or one of its ancestors. Fixes: #65097 Signed-off-by: haramjeong <04harams77@gmail.com> Assisted-by: Codex
1 parent 30c5a65 commit 25cbae4

5 files changed

Lines changed: 184 additions & 32 deletions

File tree

‎lib/internal/fs/cp/cp-sync.js‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ function cpSyncFn(src, dest, opts) {
5353
if (!shouldCopy) return;
5454
}
5555

56+
opts = { ...opts, destRoot: dest };
5657
fsBinding.cpSyncCheckPaths(src, dest, opts.dereference, opts.recursive);
5758

5859
return getStats(src, dest, opts);
@@ -71,7 +72,7 @@ function getStats(src, dest, opts) {
7172
srcStat.isBlockDevice()) {
7273
return onFile(srcStat, destStat, src, dest, opts);
7374
} else if (srcStat.isSymbolicLink()) {
74-
return onLink(destStat, src, dest, opts.verbatimSymlinks);
75+
return onLink(destStat, src, dest, opts.verbatimSymlinks, opts.destRoot);
7576
}
7677

7778
// It is not possible to get here because all possible cases are handled above.
@@ -198,7 +199,7 @@ function copyDir(src, dest, opts, mkDir, srcMode) {
198199
}
199200

200201
// TODO(@anonrig): Move this function to C++.
201-
function onLink(destStat, src, dest, verbatimSymlinks) {
202+
function onLink(destStat, src, dest, verbatimSymlinks, destRoot) {
202203
let resolvedSrc = readlinkSync(src);
203204
if (!verbatimSymlinks && !isAbsolute(resolvedSrc)) {
204205
resolvedSrc = resolve(dirname(src), resolvedSrc);
@@ -224,7 +225,13 @@ function onLink(destStat, src, dest, verbatimSymlinks) {
224225
resolvedDest = resolve(dirname(dest), resolvedDest);
225226
}
226227

227-
if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest)) {
228+
// A symlink that resolves to the same target as the destination symlink
229+
// is not a self-copy, unless the target is the destination directory
230+
// itself or one of its ancestors.
231+
const sameTarget = resolvedSrc === resolvedDest &&
232+
!isSrcSubdir(resolvedSrc, resolve(destRoot));
233+
234+
if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest) && !sameTarget) {
228235
throw new ERR_FS_CP_EINVAL({
229236
message: `cannot copy ${resolvedSrc} to a subdirectory of self ` +
230237
`${resolvedDest}`,
@@ -237,7 +244,8 @@ function onLink(destStat, src, dest, verbatimSymlinks) {
237244
// Prevent copy if src is a subdir of dest since unlinking
238245
// dest in this case would result in removing src contents
239246
// and therefore a broken symlink would be created.
240-
if (statSync(dest).isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc)) {
247+
if (statSync(dest).isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc) &&
248+
!sameTarget) {
241249
throw new ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY({
242250
message: `cannot overwrite ${resolvedDest} with ${resolvedSrc}`,
243251
path: dest,

‎lib/internal/fs/cp/cp.js‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ async function cpFn(src, dest, opts) {
6666
'node is not recommended';
6767
process.emitWarning(warning, 'TimestampPrecisionWarning');
6868
}
69+
opts = { ...opts, destRoot: dest };
6970
const stats = await checkPaths(src, dest, opts);
7071
const { srcStat, destStat, skipped } = stats;
7172
if (skipped) return;
@@ -385,7 +386,13 @@ async function onLink(destStat, src, dest, opts) {
385386
resolvedDest = resolve(dirname(dest), resolvedDest);
386387
}
387388

388-
if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest)) {
389+
// A symlink that resolves to the same target as the destination symlink
390+
// is not a self-copy, unless the target is the destination directory
391+
// itself or one of its ancestors.
392+
const sameTarget = resolvedSrc === resolvedDest &&
393+
!isSrcSubdir(resolvedSrc, resolve(opts.destRoot));
394+
395+
if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest) && !sameTarget) {
389396
throw new ERR_FS_CP_EINVAL({
390397
message: `cannot copy ${resolvedSrc} to a subdirectory of self ` +
391398
`${resolvedDest}`,
@@ -399,7 +406,8 @@ async function onLink(destStat, src, dest, opts) {
399406
// dest in this case would result in removing src contents
400407
// and therefore a broken symlink would be created.
401408
const srcStat = await stat(src);
402-
if (srcStat.isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc)) {
409+
if (srcStat.isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc) &&
410+
!sameTarget) {
403411
throw new ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY({
404412
message: `cannot overwrite ${resolvedDest} with ${resolvedSrc}`,
405413
path: dest,

‎src/node_file.cc‎

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4838,9 +4838,9 @@ CpError CopyDirRecursive(const std::filesystem::path& src_path,
48384838

48394839
std::function<CpError(std::filesystem::path, std::filesystem::path)>
48404840
copy_dir_contents;
4841-
copy_dir_contents = [&options, &copy_dir_contents, file_copy_opts](
4842-
std::filesystem::path src,
4843-
std::filesystem::path dest) -> CpError {
4841+
copy_dir_contents =
4842+
[&options, &copy_dir_contents, &dest_path, file_copy_opts](
4843+
std::filesystem::path src, std::filesystem::path dest) -> CpError {
48444844
std::error_code error;
48454845
// Only the error_code overloads are used from here on: this runs on a
48464846
// thread pool thread and exceptions are disabled.
@@ -4888,6 +4888,28 @@ CpError CopyDirRecursive(const std::filesystem::path& src_path,
48884888
return CpError::Std(error, dest_str);
48894889
}
48904890

4891+
std::filesystem::path symlink_target_absolute;
4892+
if (options.fresh_destination) {
4893+
// As path.resolve() does: lexical only, absolute targets verbatim.
4894+
symlink_target_absolute =
4895+
symlink_target.is_absolute()
4896+
? symlink_target
4897+
: std::filesystem::absolute(src / symlink_target, error)
4898+
.lexically_normal();
4899+
} else {
4900+
symlink_target_absolute = std::filesystem::weakly_canonical(
4901+
std::filesystem::absolute(src / symlink_target, error), error);
4902+
}
4903+
if (error) {
4904+
return CpError::Std(error, dest_str);
4905+
}
4906+
#ifdef _WIN32
4907+
auto wstr = symlink_target_absolute.wstring();
4908+
if (wstr.starts_with(L"\\\\?\\")) {
4909+
symlink_target_absolute = std::filesystem::path(wstr.substr(4));
4910+
}
4911+
#endif
4912+
48914913
if (std::filesystem::exists(dest_file_path, error)) {
48924914
if (std::filesystem::is_symlink(dest_file_path, error)) {
48934915
auto current_dest_symlink_target =
@@ -4896,9 +4918,33 @@ CpError CopyDirRecursive(const std::filesystem::path& src_path,
48964918
return CpError::Std(error, dest_str);
48974919
}
48984920

4921+
auto current_dest_symlink_target_absolute =
4922+
std::filesystem::weakly_canonical(
4923+
std::filesystem::absolute(dest_file_path.parent_path() /
4924+
current_dest_symlink_target,
4925+
error),
4926+
error);
4927+
if (error) {
4928+
return CpError::Std(error, dest_str);
4929+
}
4930+
#ifdef _WIN32
4931+
auto wstr2 = current_dest_symlink_target_absolute.wstring();
4932+
if (wstr2.starts_with(L"\\\\?\\")) {
4933+
current_dest_symlink_target_absolute =
4934+
std::filesystem::path(wstr2.substr(4));
4935+
}
4936+
#endif
4937+
// Equal targets are safe unless they point to the destination
4938+
// root or one of its ancestors.
4939+
bool same_target =
4940+
symlink_target_absolute ==
4941+
current_dest_symlink_target_absolute &&
4942+
!isInsideDir(symlink_target_absolute, dest_path);
4943+
48994944
if (!options.dereference &&
49004945
std::filesystem::is_directory(symlink_target, error) &&
4901-
isInsideDir(symlink_target, current_dest_symlink_target)) {
4946+
isInsideDir(symlink_target, current_dest_symlink_target) &&
4947+
!same_target) {
49024948
return {CpError::kEinval,
49034949
0,
49044950
"cp",
@@ -4912,7 +4958,8 @@ CpError CopyDirRecursive(const std::filesystem::path& src_path,
49124958
// dest in this case would result in removing src contents
49134959
// and therefore a broken symlink would be created.
49144960
if (std::filesystem::is_directory(dest_file_path, error) &&
4915-
isInsideDir(current_dest_symlink_target, symlink_target)) {
4961+
isInsideDir(current_dest_symlink_target, symlink_target) &&
4962+
!same_target) {
49164963
return {CpError::kSymlinkToSubdirectory,
49174964
0,
49184965
"cp",
@@ -4939,27 +4986,6 @@ CpError CopyDirRecursive(const std::filesystem::path& src_path,
49394986
}
49404987
}
49414988
}
4942-
std::filesystem::path symlink_target_absolute;
4943-
if (options.fresh_destination) {
4944-
// As path.resolve() does: lexical only, absolute targets verbatim.
4945-
symlink_target_absolute =
4946-
symlink_target.is_absolute()
4947-
? symlink_target
4948-
: std::filesystem::absolute(src / symlink_target, error)
4949-
.lexically_normal();
4950-
} else {
4951-
symlink_target_absolute = std::filesystem::weakly_canonical(
4952-
std::filesystem::absolute(src / symlink_target, error), error);
4953-
}
4954-
if (error) {
4955-
return CpError::Std(error, dest_str);
4956-
}
4957-
#ifdef _WIN32
4958-
auto wstr = symlink_target_absolute.wstring();
4959-
if (wstr.starts_with(L"\\\\?\\")) {
4960-
symlink_target_absolute = std::filesystem::path(wstr.substr(4));
4961-
}
4962-
#endif
49634989
if (dir_entry.is_directory(error)) {
49644990
std::filesystem::create_directory_symlink(
49654991
symlink_target_absolute, dest_file_path, error);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// This tests that repeatedly copying a directory containing a symlink
2+
// to an unrelated directory succeeds.
3+
// See https://github.com/nodejs/node/issues/65097.
4+
import { mustCall, mustNotMutateObjectDeep } from '../common/index.mjs';
5+
import { nextdir } from '../common/fs.js';
6+
import assert from 'node:assert';
7+
import { cp, mkdirSync, realpathSync, symlinkSync } from 'node:fs';
8+
import { join } from 'node:path';
9+
10+
import tmpdir from '../common/tmpdir.js';
11+
tmpdir.refresh();
12+
13+
const root = nextdir();
14+
const src = join(root, 'src');
15+
const dest = join(root, 'dest');
16+
const target = join(root, 'target');
17+
mkdirSync(src, mustNotMutateObjectDeep({ recursive: true }));
18+
mkdirSync(target);
19+
symlinkSync(target, join(src, 'link'));
20+
cp(src, dest, mustNotMutateObjectDeep({ recursive: true }), mustCall((err) => {
21+
assert.ifError(err);
22+
cp(src, dest, mustNotMutateObjectDeep({ recursive: true }), mustCall((err) => {
23+
assert.ifError(err);
24+
assert.strictEqual(realpathSync(join(dest, 'link')), realpathSync(target));
25+
26+
// Also exercise the JavaScript (filter) path.
27+
cp(src, dest, mustNotMutateObjectDeep({ recursive: true, filter: () => true }), mustCall((err) => {
28+
assert.ifError(err);
29+
cp(src, dest, mustNotMutateObjectDeep({ recursive: true, filter: () => true }), mustCall((err) => {
30+
assert.ifError(err);
31+
assert.strictEqual(realpathSync(join(dest, 'link')), realpathSync(target));
32+
}));
33+
}));
34+
}));
35+
}));
36+
37+
// A symlink with a relative target pointing to an unrelated directory.
38+
{
39+
const root = nextdir();
40+
const src = join(root, 'src');
41+
const dest = join(root, 'dest');
42+
const target = join(root, 'target');
43+
mkdirSync(src, mustNotMutateObjectDeep({ recursive: true }));
44+
mkdirSync(target);
45+
symlinkSync('../target', join(src, 'link'));
46+
cp(src, dest, mustNotMutateObjectDeep({ recursive: true }), mustCall((err) => {
47+
assert.ifError(err);
48+
cp(src, dest, mustNotMutateObjectDeep({ recursive: true }), mustCall((err) => {
49+
assert.ifError(err);
50+
assert.strictEqual(realpathSync(join(dest, 'link')), realpathSync(target));
51+
52+
// Same as above, exercising the JavaScript (filter) path.
53+
cp(src, dest, mustNotMutateObjectDeep({ recursive: true, filter: () => true }), mustCall((err) => {
54+
assert.ifError(err);
55+
cp(src, dest, mustNotMutateObjectDeep({ recursive: true, filter: () => true }), mustCall((err) => {
56+
assert.ifError(err);
57+
assert.strictEqual(realpathSync(join(dest, 'link')), realpathSync(target));
58+
}));
59+
}));
60+
}));
61+
}));
62+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// This tests that repeatedly copying a directory containing a symlink
2+
// to an unrelated directory succeeds.
3+
// See https://github.com/nodejs/node/issues/65097.
4+
import { mustNotMutateObjectDeep } from '../common/index.mjs';
5+
import { nextdir } from '../common/fs.js';
6+
import assert from 'node:assert';
7+
import { cpSync, mkdirSync, realpathSync, symlinkSync } from 'node:fs';
8+
import { join } from 'node:path';
9+
10+
import tmpdir from '../common/tmpdir.js';
11+
tmpdir.refresh();
12+
13+
const root = nextdir();
14+
const src = join(root, 'src');
15+
const dest = join(root, 'dest');
16+
const target = join(root, 'target');
17+
mkdirSync(src, mustNotMutateObjectDeep({ recursive: true }));
18+
mkdirSync(target);
19+
symlinkSync(target, join(src, 'link'));
20+
cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true }));
21+
cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true }));
22+
assert.strictEqual(realpathSync(join(dest, 'link')), realpathSync(target));
23+
24+
// Also exercise the JavaScript (filter) path. The destination symlink
25+
// already exists at this point, so this covers the repeated-copy case on
26+
// the filtered code path as well.
27+
cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true, filter: () => true }));
28+
cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true, filter: () => true }));
29+
assert.strictEqual(realpathSync(join(dest, 'link')), realpathSync(target));
30+
31+
// A symlink with a relative target pointing to an unrelated directory.
32+
{
33+
const root = nextdir();
34+
const src = join(root, 'src');
35+
const dest = join(root, 'dest');
36+
const target = join(root, 'target');
37+
mkdirSync(src, mustNotMutateObjectDeep({ recursive: true }));
38+
mkdirSync(target);
39+
symlinkSync('../target', join(src, 'link'));
40+
cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true }));
41+
cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true }));
42+
assert.strictEqual(realpathSync(join(dest, 'link')), realpathSync(target));
43+
44+
// Same as above, exercising the JavaScript (filter) path.
45+
cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true, filter: () => true }));
46+
cpSync(src, dest, mustNotMutateObjectDeep({ recursive: true, filter: () => true }));
47+
assert.strictEqual(realpathSync(join(dest, 'link')), realpathSync(target));
48+
}

0 commit comments

Comments
 (0)