symlink-trust: -k/--copy-dirlinks, -K/--keep-dirlinks, --munge-links (+real -l/--links)
CI / lint (pull_request) Successful in 1m1s
CI / sanitizers (address) (pull_request) Skipped
CI / sanitizers (undefined) (pull_request) Skipped
CI / fuzz-build (pull_request) Skipped
CI / coverage (pull_request) Skipped
CI / valgrind (pull_request) Skipped
CI / build-and-test (pull_request) Successful in 1m19s

Adds symlink-target transmission (File is_symlink+symlink_target, STATUS_SYMLINK
frame, chunk type 2), munge-links sender containment + receiver-side symmetric
target containment, keep-dirlinks confined dir-symlink following (O_NOFOLLOW
realpath-rechecked), and fixes -l to copy symlinks as symlinks. munge_links +
keep_dirlinks cross the wire; copy_dirlinks client-only. PROTOCOL_VERSION
2.12.0->2.13.0. Review fixes: receiver rejects absolute/.. targets, gated unmunge,
-K O_NOFOLLOW+re-fstat, keep_dirlinks set once at config-accept, rel_buf overflow
fails the walk.
This commit is contained in:
2026-09-08 22:27:53 +02:00
parent 0de859b302
commit 820188c2cc
22 changed files with 945 additions and 39 deletions
+3
View File
@@ -476,6 +476,9 @@ static const OptionEntry OPTION_TABLE[] = {
{"--copy-links", NULL, OPT_FLAG, offsetof(Config, copy_links)},
{"--safe-links", NULL, OPT_FLAG, offsetof(Config, safe_links)},
{"--copy-unsafe-links", NULL, OPT_FLAG, offsetof(Config, copy_unsafe_links)},
{"--copy-dirlinks", "-k", OPT_FLAG, offsetof(Config, copy_dirlinks)},
{"--keep-dirlinks", "-K", OPT_FLAG, offsetof(Config, keep_dirlinks)},
{"--munge-links", NULL, OPT_FLAG, offsetof(Config, munge_links)},
{"--hard-links", "-H", OPT_FLAG, offsetof(Config, preserve_hard_links)},
{"--sparse", "-S", OPT_FLAG, offsetof(Config, preserve_sparse)},
{"--inplace", NULL, OPT_FLAG, offsetof(Config, inplace)},
+23
View File
@@ -102,6 +102,8 @@ static bool prepare_scanner(const Config* config, int num_threads, PreparedScann
options->copy_links = config->copy_links;
options->safe_links = config->safe_links;
options->copy_unsafe_links = config->copy_unsafe_links;
options->copy_dirlinks = config->copy_dirlinks;
options->munge_links = config->munge_links;
options->checksum = config->checksum;
options->one_file_system = config->one_file_system;
options->file_list = (const FileListSet*)config->files_from_set;
@@ -1067,6 +1069,20 @@ static bool send_directory_entry(Client* client, File* file) {
return send_str(client->file_descriptor, file_wire_path(file));
}
/* Transmit one symlink entry: a STATUS_SYMLINK frame carrying the destination
* path, the (sender-munged, if --munge-links) target string, and metadata when
* negotiated. The receiver unmunges the target and creates the symlink beneath
* its root. Symlinks never need an incremental check or data payload. */
static bool send_symlink_entry(const Client* client, File* file, const Config* config) {
if (!file || !file_wire_path(file) || !file->symlink_target)
return false;
int fd = client->file_descriptor;
if (!send_status(fd, STATUS_SYMLINK) || !send_str(fd, file_wire_path(file)) ||
!send_str(fd, file->symlink_target))
return false;
return !config->use_metadata || metadata_send(fd, file->metadata);
}
// Send a single file directly via sendfile (non-incremental path).
static bool send_file_direct_sendfile(File* file, int fd, bool use_metadata, const Config* config) {
if (!send_status(fd, STATUS_NEXT))
@@ -1248,6 +1264,13 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
change_emit_file_sent(config, f);
continue;
}
/* Symlink entry (-l / -k keep-as-symlink): only the target rides the wire. */
if (f->is_symlink) {
if (!send_symlink_entry(client, f, config))
return -1;
change_emit_file_sent(config, f);
continue;
}
bool stream = f->data->data == NULL && f->data->size > 0;
bool use_sendfile =
(config->use_sendfile && !config->use_compression) || (stream && !config->use_compression);
+94 -21
View File
@@ -112,6 +112,12 @@ typedef struct {
char* path;
struct stat stats;
bool is_directory;
/* True when the entry should be carried through as a SYMLINK (is_symlink)
rather than a dereferenced file/directory. When true, `link_target` holds
the owned target string to transmit (sender-munged under --munge-links);
ownership transfers to the File built from this entry. */
bool is_symlink;
char* link_target;
/* True when the entry was pruned by a user selection rule (--filter/-C/per-dir
rules, the --exclude/--include layer, or --max-size/--min-size) rather than
skipped for another reason (unreadable, symlink policy, not applicable). */
@@ -263,6 +269,8 @@ static int scanner_inspect_entry(const ScannerOptions* options, const char* sour
const char* containing_dir, const char* name,
ScannerEntry* entry) {
entry->excluded = false;
entry->is_symlink = false;
entry->link_target = NULL;
entry->path = path_cat(containing_dir, name);
if (!entry->path)
return -1;
@@ -273,38 +281,81 @@ static int scanner_inspect_entry(const ScannerOptions* options, const char* sour
return 0;
}
bool is_symlink = S_ISLNK(link_stats.st_mode);
if (is_symlink && !options->follow_symlinks && !options->copy_links && !options->safe_links &&
!options->copy_unsafe_links)
if (!is_symlink)
goto regular;
/* Symlink: choose between dereferencing (---copy-links / --safe-links /
--copy-unsafe-links, plus -k for symlinks-to-directories) and carrying the
link through as a symlink (-l, and -k for symlinks-to-files). No link
option means the symlink is skipped entirely (pre-existing behavior). */
const bool any_link_option = options->follow_symlinks || options->copy_links ||
options->safe_links || options->copy_unsafe_links ||
options->copy_dirlinks;
if (!any_link_option)
goto skip;
if (is_symlink && options->safe_links) {
char link_target[4096];
ssize_t length = readlink(entry->path, link_target, sizeof(link_target) - 1);
if (length < 0)
goto skip;
link_target[length] = '\0';
char link_target[4096];
ssize_t length = readlink(entry->path, link_target, sizeof(link_target) - 1);
if (length < 0)
goto skip;
link_target[length] = '\0';
if (options->safe_links) {
if (link_target[0] == '/' || !safe_relative_link(source_root, containing_dir, link_target))
goto skip;
}
if (is_symlink && options->copy_unsafe_links && !options->copy_links) {
char link_target[4096];
ssize_t length = readlink(entry->path, link_target, sizeof(link_target) - 1);
if (length < 0)
goto skip;
link_target[length] = '\0';
if (options->copy_unsafe_links && !options->copy_links) {
if (link_target[0] != '/')
goto skip;
}
if (is_symlink && options->follow_symlinks && !options->copy_links)
entry->stats = link_stats;
else if (stat(entry->path, &entry->stats) != 0)
goto skip;
bool emit_symlink = false;
if (options->copy_links) {
emit_symlink = false; /* --copy-links dereferences every referent */
} else if (options->safe_links || options->copy_unsafe_links) {
emit_symlink = false; /* preserve pre-existing dereference behavior */
} else if (options->copy_dirlinks) {
struct stat ref;
if (stat(entry->path, &ref) == 0 && S_ISDIR(ref.st_mode))
emit_symlink = false; /* -k: symlink to a directory recurses as a dir */
else
emit_symlink = true; /* -k: symlink to a file stays a symlink */
} else if (options->follow_symlinks) {
emit_symlink = true; /* -l: copy symlink as symlink */
}
if (!emit_symlink) {
if (stat(entry->path, &entry->stats) != 0)
goto skip;
entry->is_directory = S_ISDIR(entry->stats.st_mode);
if (entry->is_directory)
return 1;
goto apply_filters;
}
/* Carry the link as a symlink. --munge-links containment: a target that
could escape the receive root (absolute or containing "..") is never
transmitted -- the entry is merely skipped ("contained"). */
if (link_target[0] == '\0' ||
(options->munge_links && !file_symlink_target_contained(link_target)))
goto skip;
entry->is_symlink = true;
entry->stats = link_stats;
entry->is_directory = false;
entry->link_target =
options->munge_links ? file_symlink_munge(link_target) : str_dup(link_target);
if (!entry->link_target)
goto skip;
goto apply_filters;
regular:
if (stat(entry->path, &entry->stats) != 0)
goto skip;
entry->is_directory = S_ISDIR(entry->stats.st_mode);
if (entry->is_directory)
return 1;
apply_filters:
for (int i = 0; i < options->exclude_count; i++)
if (glob_match(options->exclude_patterns[i], name)) {
entry->excluded = true;
@@ -330,6 +381,8 @@ static int scanner_inspect_entry(const ScannerOptions* options, const char* sour
skip:
free(entry->path);
entry->path = NULL;
free(entry->link_target);
entry->link_target = NULL;
return 0;
}
@@ -363,6 +416,8 @@ DirectoryScanner* directory_scanner_create_with_options(const char* root_directo
scanner->copy_links = options->copy_links;
scanner->safe_links = options->safe_links;
scanner->copy_unsafe_links = options->copy_unsafe_links;
scanner->copy_dirlinks = options->copy_dirlinks;
scanner->munge_links = options->munge_links;
scanner->checksum = options->checksum;
scanner->one_file_system = options->one_file_system;
scanner->failed = false;
@@ -822,6 +877,8 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
.copy_links = scanner->copy_links,
.safe_links = scanner->safe_links,
.copy_unsafe_links = scanner->copy_unsafe_links,
.copy_dirlinks = scanner->copy_dirlinks,
.munge_links = scanner->munge_links,
.checksum = scanner->checksum,
.one_file_system = scanner->one_file_system,
.file_list = scanner->file_list,
@@ -917,10 +974,18 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
free(cur_path);
if (file == NULL) {
free(rel_copy);
free(inspected.link_target);
inspected.link_target = NULL;
scanner->failed = true;
continue;
}
file->data->size = stats.st_size;
if (inspected.is_symlink) {
file->is_symlink = true;
file->symlink_target = inspected.link_target;
inspected.link_target = NULL;
} else {
file->data->size = stats.st_size;
}
if (scanner->relative_mode) {
file->send_path = rel_copy;
rel_copy = NULL;
@@ -1235,10 +1300,18 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
free(cur_path);
if (!file) {
free(rel);
free(inspected.link_target);
inspected.link_target = NULL;
ps->failed = true;
return;
}
file->data->size = st.st_size;
if (inspected.is_symlink) {
file->is_symlink = true;
file->symlink_target = inspected.link_target;
inspected.link_target = NULL;
} else {
file->data->size = st.st_size;
}
if (use_rel) {
file->send_path = rel;
rel = NULL;
+10
View File
@@ -32,6 +32,14 @@ typedef struct {
bool copy_links;
bool safe_links;
bool copy_unsafe_links;
/* Phase 4 symlink-trust sender options: -k/--copy-dirlinks (dereference a
* symlink to a directory as a directory, keeping symlinks-to-files as
* symlinks) and --munge-links (rewrite each transmitted symlink target with a
* marker; escaping targets are never transmitted). Both are client/sender
* side only and never serialized to the wire (keep_dirlinks is the
* receiver-side counterpart). */
bool copy_dirlinks;
bool munge_links;
bool checksum;
bool one_file_system;
/* Phase 2 (files-from / filter layer). All pointers are shared read-only
@@ -100,6 +108,8 @@ typedef struct {
bool copy_links;
bool safe_links;
bool copy_unsafe_links;
bool copy_dirlinks;
bool munge_links;
bool checksum;
bool one_file_system;
dev_t root_dev;
+3
View File
@@ -180,6 +180,9 @@ void print_usage(void) {
printf(" --copy-links Transform symlinks into referent files\n");
printf(" --safe-links Skip symlinks that point outside transfer tree\n");
printf(" --copy-unsafe-links Only transform unsafe symlinks into referent files\n");
printf(" -k, --copy-dirlinks Transform symlinks to directories into real dirs\n");
printf(" -K, --keep-dirlinks Keep an existing symlink-to-dir as that dir\n");
printf(" --munge-links Munge symlink targets on the wire (sender)\n");
printf(" -H, --hard-links Preserve hard-link relationships across the transfer\n");
printf(" -S, --sparse Handle sparse files efficiently\n");
printf(" --inplace Update files in-place (no temp+rename)\n");