Skip to content

Commit 375eaa8

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>
1 parent 695f01f commit 375eaa8

5 files changed

Lines changed: 165 additions & 16 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.
@@ -189,7 +190,7 @@ function copyDir(src, dest, opts, mkDir, srcMode) {
189190
}
190191

191192
// TODO(@anonrig): Move this function to C++.
192-
function onLink(destStat, src, dest, verbatimSymlinks) {
193+
function onLink(destStat, src, dest, verbatimSymlinks, destRoot) {
193194
let resolvedSrc = readlinkSync(src);
194195
if (!verbatimSymlinks && !isAbsolute(resolvedSrc)) {
195196
resolvedSrc = resolve(dirname(src), resolvedSrc);
@@ -215,7 +216,13 @@ function onLink(destStat, src, dest, verbatimSymlinks) {
215216
resolvedDest = resolve(dirname(dest), resolvedDest);
216217
}
217218

218-
if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest)) {
219+
// A symlink that resolves to the same target as the destination symlink
220+
// is not a self-copy, unless the target is the destination directory
221+
// itself or one of its ancestors.
222+
const sameTarget = resolvedSrc === resolvedDest &&
223+
!isSrcSubdir(resolvedSrc, resolve(destRoot));
224+
225+
if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest) && !sameTarget) {
219226
throw new ERR_FS_CP_EINVAL({
220227
message: `cannot copy ${resolvedSrc} to a subdirectory of self ` +
221228
`${resolvedDest}`,
@@ -228,7 +235,8 @@ function onLink(destStat, src, dest, verbatimSymlinks) {
228235
// Prevent copy if src is a subdir of dest since unlinking
229236
// dest in this case would result in removing src contents
230237
// and therefore a broken symlink would be created.
231-
if (statSync(dest).isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc)) {
238+
if (statSync(dest).isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc) &&
239+
!sameTarget) {
232240
throw new ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY({
233241
message: `cannot overwrite ${resolvedDest} with ${resolvedSrc}`,
234242
path: dest,

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ async function cpFn(src, dest, opts) {
6363
'node is not recommended';
6464
process.emitWarning(warning, 'TimestampPrecisionWarning');
6565
}
66+
opts = { ...opts, destRoot: dest };
6667
const stats = await checkPaths(src, dest, opts);
6768
const { srcStat, destStat, skipped } = stats;
6869
if (skipped) return;
@@ -363,7 +364,13 @@ async function onLink(destStat, src, dest, opts) {
363364
resolvedDest = resolve(dirname(dest), resolvedDest);
364365
}
365366

366-
if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest)) {
367+
// A symlink that resolves to the same target as the destination symlink
368+
// is not a self-copy, unless the target is the destination directory
369+
// itself or one of its ancestors.
370+
const sameTarget = resolvedSrc === resolvedDest &&
371+
!isSrcSubdir(resolvedSrc, resolve(opts.destRoot));
372+
373+
if (srcIsDir && isSrcSubdir(resolvedSrc, resolvedDest) && !sameTarget) {
367374
throw new ERR_FS_CP_EINVAL({
368375
message: `cannot copy ${resolvedSrc} to a subdirectory of self ` +
369376
`${resolvedDest}`,
@@ -377,7 +384,8 @@ async function onLink(destStat, src, dest, opts) {
377384
// dest in this case would result in removing src contents
378385
// and therefore a broken symlink would be created.
379386
const srcStat = await stat(src);
380-
if (srcStat.isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc)) {
387+
if (srcStat.isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc) &&
388+
!sameTarget) {
381389
throw new ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY({
382390
message: `cannot overwrite ${resolvedDest} with ${resolvedSrc}`,
383391
path: dest,

‎src/node_file.cc‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4748,6 +4748,7 @@ static void CpSyncCopyDir(const FunctionCallbackInfo<Value>& args) {
47484748
force,
47494749
error_on_exist,
47504750
dereference,
4751+
dest_path,
47514752
&isolate](std::filesystem::path src,
47524753
std::filesystem::path dest) {
47534754
std::error_code error;
@@ -4771,6 +4772,15 @@ static void CpSyncCopyDir(const FunctionCallbackInfo<Value>& args) {
47714772
return false;
47724773
}
47734774

4775+
auto symlink_target_absolute = std::filesystem::weakly_canonical(
4776+
std::filesystem::absolute(src / symlink_target));
4777+
#ifdef _WIN32
4778+
auto wstr = symlink_target_absolute.wstring();
4779+
if (wstr.starts_with(L"\\\\?\\")) {
4780+
symlink_target_absolute = std::filesystem::path(wstr.substr(4));
4781+
}
4782+
#endif
4783+
47744784
if (std::filesystem::exists(dest_file_path)) {
47754785
if (std::filesystem::is_symlink((dest_file_path.c_str()))) {
47764786
auto current_dest_symlink_target =
@@ -4780,9 +4790,29 @@ static void CpSyncCopyDir(const FunctionCallbackInfo<Value>& args) {
47804790
return false;
47814791
}
47824792

4793+
// A symlink that resolves to the same target as the destination
4794+
// symlink is not a self-copy, unless the target is the
4795+
// destination directory itself or one of its ancestors.
4796+
auto current_dest_symlink_target_absolute =
4797+
std::filesystem::weakly_canonical(
4798+
std::filesystem::absolute(dest_file_path.parent_path() /
4799+
current_dest_symlink_target));
4800+
#ifdef _WIN32
4801+
auto wstr2 = current_dest_symlink_target_absolute.wstring();
4802+
if (wstr2.starts_with(L"\\\\?\\")) {
4803+
current_dest_symlink_target_absolute =
4804+
std::filesystem::path(wstr2.substr(4));
4805+
}
4806+
#endif
4807+
bool same_target =
4808+
symlink_target_absolute ==
4809+
current_dest_symlink_target_absolute &&
4810+
!isInsideDir(symlink_target_absolute, dest_path);
4811+
47834812
if (!dereference &&
47844813
std::filesystem::is_directory(symlink_target) &&
4785-
isInsideDir(symlink_target, current_dest_symlink_target)) {
4814+
isInsideDir(symlink_target, current_dest_symlink_target) &&
4815+
!same_target) {
47864816
static constexpr const char* message =
47874817
"Cannot copy %s to a subdirectory of self %s";
47884818
THROW_ERR_FS_CP_EINVAL(
@@ -4794,7 +4824,8 @@ static void CpSyncCopyDir(const FunctionCallbackInfo<Value>& args) {
47944824
// dest in this case would result in removing src contents
47954825
// and therefore a broken symlink would be created.
47964826
if (std::filesystem::is_directory(dest_file_path) &&
4797-
isInsideDir(current_dest_symlink_target, symlink_target)) {
4827+
isInsideDir(current_dest_symlink_target, symlink_target) &&
4828+
!same_target) {
47984829
static constexpr const char* message =
47994830
"cannot overwrite %s with %s";
48004831
THROW_ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY(
@@ -4821,14 +4852,6 @@ static void CpSyncCopyDir(const FunctionCallbackInfo<Value>& args) {
48214852
}
48224853
}
48234854
}
4824-
auto symlink_target_absolute = std::filesystem::weakly_canonical(
4825-
std::filesystem::absolute(src / symlink_target));
4826-
#ifdef _WIN32
4827-
auto wstr = symlink_target_absolute.wstring();
4828-
if (wstr.starts_with(L"\\\\?\\")) {
4829-
symlink_target_absolute = std::filesystem::path(wstr.substr(4));
4830-
}
4831-
#endif
48324855
if (dir_entry.is_directory()) {
48334856
std::filesystem::create_directory_symlink(
48344857
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)