Skip to content

Commit 346ed1c

Browse files
fs: honor dereference for symlinks nested in cpSync trees
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. Dangling links have no target to copy and are reported instead. Signed-off-by: Christian Aurich <christian.aurichzm@gmail.com>
1 parent e06b0f1 commit 346ed1c

2 files changed

Lines changed: 243 additions & 2 deletions

File tree

src/node_file.cc

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4114,7 +4114,27 @@ static void CpSyncCopyDir(const FunctionCallbackInfo<Value>& args) {
41144114
auto dest_file_path = dest / dir_entry.path().filename();
41154115
auto dest_str = ConvertPathToUTF8(dest);
41164116

4117-
if (dir_entry.is_symlink()) {
4117+
// With dereference, links that resolve to a directory or a regular file
4118+
// fall through to the branches below, which follow symlinks. A dangling
4119+
// link has no target to copy and is reported here.
4120+
const bool is_symlink = dir_entry.is_symlink();
4121+
const bool copy_as_symlink = is_symlink && !dereference;
4122+
4123+
if (is_symlink && dereference) {
4124+
std::error_code target_error;
4125+
if (!std::filesystem::exists(dir_entry.path(), target_error)) {
4126+
auto entry_str = ConvertPathToUTF8(dir_entry.path());
4127+
env->ThrowStdErrException(
4128+
target_error
4129+
? target_error
4130+
: std::make_error_code(std::errc::no_such_file_or_directory),
4131+
"cp",
4132+
entry_str.c_str());
4133+
return false;
4134+
}
4135+
}
4136+
4137+
if (copy_as_symlink) {
41184138
if (verbatim_symlinks) {
41194139
std::filesystem::copy_symlink(
41204140
dir_entry.path(), dest_file_path, error);
@@ -4202,12 +4222,66 @@ static void CpSyncCopyDir(const FunctionCallbackInfo<Value>& args) {
42024222
}
42034223
} else if (dir_entry.is_directory()) {
42044224
auto entry_dir_path = src / dir_entry.path().filename();
4205-
std::filesystem::create_directory(dest_file_path);
4225+
if (is_symlink && dereference) {
4226+
// Mirror the JavaScript walk: create the destination only when it
4227+
// does not exist, otherwise recurse into the existing path.
4228+
std::error_code dest_error;
4229+
const bool dest_exists =
4230+
std::filesystem::exists(dest_file_path, dest_error);
4231+
if (dest_error) {
4232+
env->ThrowStdErrException(dest_error, "cp", dest_str.c_str());
4233+
return false;
4234+
}
4235+
if (!dest_exists) {
4236+
std::filesystem::create_directory(dest_file_path, dest_error);
4237+
if (dest_error) {
4238+
env->ThrowStdErrException(dest_error, "cp", dest_str.c_str());
4239+
return false;
4240+
}
4241+
}
4242+
} else {
4243+
std::filesystem::create_directory(dest_file_path);
4244+
}
42064245
auto success = copy_dir_contents(entry_dir_path, dest_file_path);
42074246
if (!success) {
42084247
return false;
42094248
}
42104249
} else if (dir_entry.is_regular_file()) {
4250+
if (is_symlink && dereference) {
4251+
// Only a dereferenced link reaches this branch as a link, so what an
4252+
// occupied destination means here is settled the way the JavaScript
4253+
// walk settles it: replaced under force, left untouched otherwise.
4254+
// Replacing an existing destination unlinks the entry first, which is
4255+
// what keeps an existing link there from being written through.
4256+
std::error_code dest_error;
4257+
const bool dest_exists =
4258+
std::filesystem::exists(dest_file_path, dest_error);
4259+
if (dest_error) {
4260+
env->ThrowStdErrException(dest_error, "cp", dest_str.c_str());
4261+
return false;
4262+
}
4263+
4264+
if (dest_exists) {
4265+
if (!force) {
4266+
if (error_on_exist) {
4267+
THROW_ERR_FS_CP_EEXIST(
4268+
isolate,
4269+
"[ERR_FS_CP_EEXIST]: Target already exists: "
4270+
"cp returned EEXIST (%s already exists)",
4271+
dest_file_path);
4272+
return false;
4273+
}
4274+
continue;
4275+
}
4276+
4277+
std::filesystem::remove(dest_file_path, dest_error);
4278+
if (dest_error) {
4279+
env->ThrowStdErrException(dest_error, "cp", dest_str.c_str());
4280+
return false;
4281+
}
4282+
}
4283+
}
4284+
42114285
std::filesystem::copy_file(
42124286
dir_entry.path(), dest_file_path, file_copy_opts, error);
42134287
if (error) {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// This tests that cpSync dereferences symlinks found inside the copied tree,
2+
// not only a symlink passed as src.
3+
import { mustNotMutateObjectDeep } from '../common/index.mjs';
4+
import { nextdir } from '../common/fs.js';
5+
import assert from 'node:assert';
6+
import { cpSync, lstatSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
7+
import { basename, join } from 'node:path';
8+
import tmpdir from '../common/tmpdir.js';
9+
10+
tmpdir.refresh();
11+
12+
const src = nextdir();
13+
const target = nextdir();
14+
const dest = nextdir();
15+
16+
mkdirSync(src, { recursive: true });
17+
mkdirSync(join(target, 'dir'), { recursive: true });
18+
writeFileSync(join(target, 'file.txt'), 'file', 'utf8');
19+
writeFileSync(join(target, 'dir', 'nested.txt'), 'nested', 'utf8');
20+
// Relative, as in the report: the link is resolved against its own directory.
21+
symlinkSync(join('..', basename(target), 'file.txt'), join(src, 'link-to-file'));
22+
symlinkSync(join(target, 'dir'), join(src, 'link-to-dir'), 'dir');
23+
24+
cpSync(src, dest, mustNotMutateObjectDeep({ dereference: true, recursive: true }));
25+
26+
assert(!lstatSync(join(dest, 'link-to-file')).isSymbolicLink());
27+
assert.strictEqual(readFileSync(join(dest, 'link-to-file'), 'utf8'), 'file');
28+
29+
assert(!lstatSync(join(dest, 'link-to-dir')).isSymbolicLink());
30+
assert.strictEqual(readFileSync(join(dest, 'link-to-dir', 'nested.txt'), 'utf8'), 'nested');
31+
32+
// A dangling link has no target to copy.
33+
const dangling = nextdir();
34+
mkdirSync(dangling, { recursive: true });
35+
symlinkSync(join(target, 'missing.txt'), join(dangling, 'link'));
36+
assert.throws(
37+
() => cpSync(dangling, nextdir(),
38+
mustNotMutateObjectDeep({ dereference: true, recursive: true })),
39+
{ code: 'ENOENT' },
40+
);
41+
42+
// A symlink cycle fails with ELOOP instead of recursing indefinitely.
43+
const looping = nextdir();
44+
mkdirSync(looping, { recursive: true });
45+
symlinkSync(looping, join(looping, 'loop'), 'dir');
46+
assert.throws(
47+
() => cpSync(looping, nextdir(),
48+
mustNotMutateObjectDeep({ dereference: true, recursive: true })),
49+
{ code: 'ELOOP' },
50+
);
51+
52+
// Under force, an existing destination link is replaced rather than written
53+
// through. Whether replacement happens at all still follows force and
54+
// errorOnExist.
55+
function withDestLink() {
56+
const outside = nextdir();
57+
const from = nextdir();
58+
const to = nextdir();
59+
mkdirSync(outside, { recursive: true });
60+
mkdirSync(from, { recursive: true });
61+
mkdirSync(to, { recursive: true });
62+
writeFileSync(join(outside, 'untouched.txt'), 'untouched', 'utf8');
63+
symlinkSync(join(target, 'file.txt'), join(from, 'entry'));
64+
symlinkSync(join(outside, 'untouched.txt'), join(to, 'entry'));
65+
return { outside, from, to };
66+
}
67+
68+
{
69+
const { outside, from, to } = withDestLink();
70+
cpSync(from, to, mustNotMutateObjectDeep({ dereference: true, recursive: true }));
71+
assert(!lstatSync(join(to, 'entry')).isSymbolicLink());
72+
assert.strictEqual(readFileSync(join(to, 'entry'), 'utf8'), 'file');
73+
assert.strictEqual(readFileSync(join(outside, 'untouched.txt'), 'utf8'), 'untouched');
74+
}
75+
76+
{
77+
const { outside, from, to } = withDestLink();
78+
cpSync(from, to, mustNotMutateObjectDeep({
79+
dereference: true, recursive: true, force: false,
80+
}));
81+
assert(lstatSync(join(to, 'entry')).isSymbolicLink());
82+
assert.strictEqual(readFileSync(join(outside, 'untouched.txt'), 'utf8'), 'untouched');
83+
}
84+
85+
{
86+
const { outside, from, to } = withDestLink();
87+
assert.throws(
88+
() => cpSync(from, to, mustNotMutateObjectDeep({
89+
dereference: true, recursive: true, force: false, errorOnExist: true,
90+
})),
91+
{ code: 'ERR_FS_CP_EEXIST' },
92+
);
93+
assert(lstatSync(join(to, 'entry')).isSymbolicLink());
94+
assert.strictEqual(readFileSync(join(outside, 'untouched.txt'), 'utf8'), 'untouched');
95+
}
96+
97+
// A link resolving to a directory descends into whatever already occupies the
98+
// destination path: a file there fails the way copying into it fails, and a
99+
// link to a directory is followed and merged into.
100+
{
101+
const from = nextdir();
102+
const to = nextdir();
103+
mkdirSync(from, { recursive: true });
104+
mkdirSync(to, { recursive: true });
105+
symlinkSync(join(target, 'dir'), join(from, 'entry'), 'dir');
106+
writeFileSync(join(to, 'entry'), 'occupied', 'utf8');
107+
assert.throws(
108+
() => cpSync(from, to,
109+
mustNotMutateObjectDeep({ dereference: true, recursive: true })),
110+
{ code: 'ENOTDIR' },
111+
);
112+
}
113+
114+
{
115+
const existing = nextdir();
116+
const from = nextdir();
117+
const to = nextdir();
118+
mkdirSync(existing, { recursive: true });
119+
mkdirSync(from, { recursive: true });
120+
mkdirSync(to, { recursive: true });
121+
writeFileSync(join(existing, 'kept.txt'), 'kept', 'utf8');
122+
symlinkSync(join(target, 'dir'), join(from, 'entry'), 'dir');
123+
symlinkSync(existing, join(to, 'entry'), 'dir');
124+
125+
cpSync(from, to, mustNotMutateObjectDeep({ dereference: true, recursive: true }));
126+
127+
assert(lstatSync(join(to, 'entry')).isSymbolicLink());
128+
assert.strictEqual(readFileSync(join(existing, 'kept.txt'), 'utf8'), 'kept');
129+
assert.strictEqual(readFileSync(join(existing, 'nested.txt'), 'utf8'), 'nested');
130+
}
131+
132+
// A directory occupying the destination path is an occupied destination like
133+
// any other, so the same force and errorOnExist rules decide its fate.
134+
function withDestDir() {
135+
const from = nextdir();
136+
const to = nextdir();
137+
mkdirSync(from, { recursive: true });
138+
mkdirSync(join(to, 'entry'), { recursive: true });
139+
symlinkSync(join(target, 'file.txt'), join(from, 'entry'));
140+
return { from, to };
141+
}
142+
143+
{
144+
const { from, to } = withDestDir();
145+
cpSync(from, to, mustNotMutateObjectDeep({ dereference: true, recursive: true }));
146+
assert(lstatSync(join(to, 'entry')).isFile());
147+
assert.strictEqual(readFileSync(join(to, 'entry'), 'utf8'), 'file');
148+
}
149+
150+
{
151+
const { from, to } = withDestDir();
152+
cpSync(from, to, mustNotMutateObjectDeep({
153+
dereference: true, recursive: true, force: false,
154+
}));
155+
assert(lstatSync(join(to, 'entry')).isDirectory());
156+
}
157+
158+
{
159+
const { from, to } = withDestDir();
160+
assert.throws(
161+
() => cpSync(from, to, mustNotMutateObjectDeep({
162+
dereference: true, recursive: true, force: false, errorOnExist: true,
163+
})),
164+
{ code: 'ERR_FS_CP_EEXIST' },
165+
);
166+
assert(lstatSync(join(to, 'entry')).isDirectory());
167+
}

0 commit comments

Comments
 (0)