Files
FastSync/src/shared/file_receive.c
T

3326 lines
131 KiB
C

#include <errno.h>
#include <dirent.h>
#include <fcntl.h>
#include <libgen.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/sysmacros.h>
#include <unistd.h>
#include "array_list.h"
#include "charset.h"
#include "chmod.h"
#include "chunk.h"
#include "compression.h"
#include "config.h"
#include "data.h"
#include "delay_updates.h"
#include "delta.h"
#include "file.h"
#include "format.h"
#include "identity.h"
#include "log.h"
#include "metadata.h"
#include "protocol.h"
#include "utils.h"
#include "xattr.h"
#define MAX_SERVER_DELETE_COUNT 100000U
#define MAX_FILE_DATA_SIZE MAX_RECEIVE_WHOLE_FILE_SIZE
/* Retained cost of one delete-manifest entry beyond its path bytes: the
ArrayList pointer slot plus an approximate malloc header/rounding for the
heap copy. Charged against MAX_MANIFEST_BYTES so a frame full of tiny paths
cannot retain far more than the byte budget (B5). */
#define MANIFEST_ENTRY_OVERHEAD (sizeof(char*) + 16)
bool file_save_to_disk(const char* root_directory, const File* file, const Config* config) {
return file_save_to_disk_full(root_directory, file, config) != FILE_SAVE_ERROR;
}
/* --delay-updates receiver path: write the file into a private staging tree
below the receive root instead of its final destination, and remember it so
it can be atomically renamed into place only once the whole transfer has
succeeded. Existence/update policies (--existing/--ignore-existing/--update)
are decided against the FINAL destination path at stage time so the run
decides exactly what an immediate (non-delayed) run would decide; the staged
file is then never re-checked at publication. Backups are deferred to
publication so the final destination is untouched until the transfer ends. */
static FileSaveResult file_stage_delayed_update(const char* root_directory,
const char* destination_path, const File* file,
Config* config) {
if (!config)
return FILE_SAVE_ERROR;
bool sparse = config->preserve_sparse;
FileAttrPolicy policy = file_attr_policy_from_config(config);
if (config->existing && !file_path_exists_secure(destination_path))
return FILE_SAVE_SKIPPED;
if (config->ignore_existing && file_path_exists_secure(destination_path))
return FILE_SAVE_SKIPPED;
if (config->update && file_destination_is_newer_secure(destination_path, file->metadata))
return FILE_SAVE_SKIPPED;
FileMetadata adjusted_metadata;
const FileMetadata* metadata = file->metadata;
if (metadata && config->chmod_spec && *config->chmod_spec) {
adjusted_metadata = *metadata;
if (!chmod_apply(adjusted_metadata.mode, config->chmod_spec, &adjusted_metadata.mode))
return FILE_SAVE_ERROR;
metadata = &adjusted_metadata;
}
if (!config->delay_context) {
config->delay_context = delay_updates_context_create(root_directory);
if (!config->delay_context)
return FILE_SAVE_ERROR;
}
DelayUpdatesContext* context = config->delay_context;
if (!delay_updates_prepare(context))
return FILE_SAVE_ERROR;
char* staged_path = path_cat(context->staging_root, file->path);
if (!staged_path)
return FILE_SAVE_ERROR;
/* The staged location is brand new (stale leftovers from a prior crash were
wiped by prepare), so the plain atomic temp+rename engine installs the
complete file there. --temp-dir scratch is deliberately not layered on
top of the delay-updates staging tree. A --link-dest basis file is hard
linked into the staging tree (so publication's rename keeps the link). */
bool ok;
if (file->basis_link) {
ok = file_to_disk_secure_link(staged_path, file->basis_link, file->data->data, file->data->size,
config->preallocate, metadata, policy, config->use_fsync, NULL);
} else {
ok =
file_to_disk_secure_attrs(staged_path, file->data->data, file->data->size, false, sparse,
config->preallocate, metadata, policy, false, false,
config->use_fsync, file->xattrs, config->fake_super, false, NULL);
}
if (!ok) {
free(staged_path);
return FILE_SAVE_ERROR;
}
if (!delay_updates_record(context, staged_path, destination_path, file->path)) {
unlink(staged_path);
free(staged_path);
return FILE_SAVE_ERROR;
}
free(staged_path);
return FILE_SAVE_WRITTEN;
}
/* Read the whole content of a confined regular file (used to fall back to a
byte-identical copy when a hard-link sibling's link() fails). Symlink-safe
(parent resolved via file_open_secure_parent + O_NOFOLLOW). A zero-length
file yields *out_size 0 and *out_buf NULL as a SUCCESS. Returns false only
on a real error/read failure, setting *source_absent to true when the reason
was that the path does not exist (ENOENT/ENOTDIR), so the caller can decide
between an abort and a graceful skip. */
static bool hardlink_read_source(const char* path, void** out_buf, unsigned long long* out_size,
bool* source_absent) {
*out_buf = NULL;
*out_size = 0;
*source_absent = false;
if (!path)
return false;
char* leaf = NULL;
int parent_fd = file_open_secure_parent(path, &leaf, false);
if (parent_fd < 0) {
*source_absent = errno == ENOENT || errno == ENOTDIR;
return false;
}
/* O_NONBLOCK is a no-op for a regular file but makes openat() fail/succeed
immediately for a client-planted FIFO instead of blocking the receive
thread forever; the post-open S_ISREG gate below is the actual type check. */
int fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
int saved_errno = errno;
free(leaf);
close(parent_fd);
if (fd < 0) {
*source_absent = saved_errno == ENOENT || saved_errno == ENOTDIR;
return false;
}
struct stat st;
if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode)) {
close(fd);
return false;
}
unsigned long long size = (unsigned long long)st.st_size;
if (size > MAX_RECEIVE_WHOLE_FILE_SIZE || size > SIZE_MAX) {
close(fd);
return false;
}
if (size == 0) {
close(fd);
return true;
}
void* buf = protocol_alloc((size_t)size);
if (!buf) {
close(fd);
return false;
}
size_t got = 0;
while (got < (size_t)size) {
ssize_t n = read(fd, (char*)buf + got, (size_t)size - got);
if (n <= 0) {
free(buf);
close(fd);
return false;
}
got += (size_t)n;
}
close(fd);
*out_buf = buf;
*out_size = size;
return true;
}
/* The group's first member's installed file is absent, but its destination
path was validated (a sibling is only ever processed after its group's first
member). When the sibling's OWN destination already exists it should be
left alone -- a clean skip -- rather than aborting the whole transfer (the
asymmetric --existing case: the first member was skipped because its
destination was missing, while the sibling already has one). Only when the
sibling's destination is missing too is this a genuine failure to
link/copy, which aborts. */
static FileSaveResult hardlink_sibling_absent_first(const char* destination_path) {
if (destination_path && file_path_exists_secure(destination_path))
return FILE_SAVE_SKIPPED;
return FILE_SAVE_ERROR;
}
/* Install a --hard-links/-H sibling: the destination entry is atomically
replaced (temp + rename) with a hard link to the group's first member. The
first member is guaranteed already installed at `hardlink_target` under the
root because -H relies on the receiver's single-FIFO-writer pipeline (one
receive thread, one write thread, FIFO queue => wire order == write order)
plus the sender's forced sequential scan, so a sibling is always processed
after its group's first member. When link() fails (different filesystem,
filesystem refuses links) a byte-identical copy of the first member is
written instead, so the result is never partial or corrupt. With
--delay-updates the sibling is staged as a hard link to the first member's
STAGED file (publication's renames preserve the shared inode). The final
--existing/--ignore-existing/--update policies are decided against the final
destination like every normal write. */
static FileSaveResult file_save_hardlink_sibling(const char* root_directory, const File* file,
const Config* config) {
Config* cfg = (Config*)config;
if (!root_directory || !file || !file->path || !file->hardlink_target)
return FILE_SAVE_ERROR;
char* destination_path = path_cat(root_directory, file->path);
if (!destination_path)
return FILE_SAVE_ERROR;
if (cfg->existing && !file_path_exists_secure(destination_path)) {
free(destination_path);
return FILE_SAVE_SKIPPED;
}
if (cfg->ignore_existing && file_path_exists_secure(destination_path)) {
free(destination_path);
return FILE_SAVE_SKIPPED;
}
if (cfg->update && file_destination_is_newer_secure(destination_path, file->metadata)) {
free(destination_path);
return FILE_SAVE_SKIPPED;
}
bool preallocate = cfg && cfg->preallocate;
FileAttrPolicy policy = file_attr_policy_from_config(cfg);
bool use_fsync = cfg && cfg->use_fsync;
if (cfg->delay_updates) {
if (!cfg->delay_context) {
cfg->delay_context = delay_updates_context_create(root_directory);
if (!cfg->delay_context) {
free(destination_path);
return FILE_SAVE_ERROR;
}
}
if (!delay_updates_prepare(cfg->delay_context)) {
free(destination_path);
return FILE_SAVE_ERROR;
}
char* staged_first = path_cat(cfg->delay_context->staging_root, file->hardlink_target);
char* staged_sibling = path_cat(cfg->delay_context->staging_root, file->path);
if (!staged_first || !staged_sibling) {
free(staged_first);
free(staged_sibling);
free(destination_path);
return FILE_SAVE_ERROR;
}
void* content = NULL;
unsigned long long content_size = 0;
bool source_absent = false;
if (!hardlink_read_source(staged_first, &content, &content_size, &source_absent)) {
FileSaveResult absent_result =
source_absent ? hardlink_sibling_absent_first(destination_path) : FILE_SAVE_ERROR;
free(staged_first);
free(staged_sibling);
free(destination_path);
return absent_result;
}
FileXattrList* sibling_xattrs =
cfg->use_xattrs ? xattr_capture_path(staged_first, cfg->preserve_acls) : NULL;
bool ok = file_to_disk_secure_link_attrs(staged_sibling, staged_first, content, content_size,
preallocate, file->metadata, policy, use_fsync,
sibling_xattrs, cfg ? cfg->fake_super : false, NULL);
xattr_list_free(sibling_xattrs);
free(content);
if (ok)
ok = delay_updates_record(cfg->delay_context, staged_sibling, destination_path, file->path);
if (!ok)
unlink(staged_sibling);
free(staged_first);
free(staged_sibling);
free(destination_path);
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
}
char* first_disk = path_cat(root_directory, file->hardlink_target);
if (!first_disk) {
free(destination_path);
return FILE_SAVE_ERROR;
}
void* content = NULL;
unsigned long long content_size = 0;
bool source_absent = false;
if (!hardlink_read_source(first_disk, &content, &content_size, &source_absent)) {
FileSaveResult absent_result =
source_absent ? hardlink_sibling_absent_first(destination_path) : FILE_SAVE_ERROR;
free(first_disk);
free(destination_path);
return absent_result;
}
/* Resolve a relative --temp-dir under the destination root, exactly as the
* primary save path does; an absolute or `..`-escaping value is rejected. */
char* resolved_temp = NULL;
if (cfg->temp_dir) {
if (cfg->temp_dir[0] == '/' || has_path_traversal(cfg->temp_dir)) {
free(content);
free(first_disk);
free(destination_path);
return FILE_SAVE_ERROR;
}
resolved_temp = path_cat(root_directory, cfg->temp_dir);
if (!resolved_temp) {
free(content);
free(first_disk);
free(destination_path);
return FILE_SAVE_ERROR;
}
}
FileXattrList* sibling_xattrs =
cfg->use_xattrs ? xattr_capture_path(first_disk, cfg->preserve_acls) : NULL;
bool ok = file_to_disk_secure_link_attrs(
destination_path, first_disk, content, content_size, preallocate, file->metadata, policy,
use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, resolved_temp);
xattr_list_free(sibling_xattrs);
free(resolved_temp);
free(content);
free(first_disk);
free(destination_path);
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
}
/* Validate a transmitted special rdev against the node kind implied by `mode`'s
* S_IFMT bits. Char/block devices require a legal major/minor pair (non-negative,
* range-checked); a non-device special (FIFO/socket) must carry an empty rdev.
* Used identically on the wire path and at the secure recreation site so a
* malicious/bogus rdev can never drive a dangerous node. */
bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode) {
bool is_device = S_ISCHR(mode) || S_ISBLK(mode);
if (is_device)
return major >= 0 && minor >= 0 && major <= 0xffff && minor <= 0x00ffffff;
/* A non-device entry must actually be a special (FIFO/socket) and carry no
rdev; a regular/dir mode is never a valid special node. */
return (S_ISFIFO(mode) || S_ISSOCK(mode)) && major == 0 && minor == 0;
}
/* ---- Device/special node RECREATION (--devices/--specials), receiver side ----
*
* Privilege gating: making a real device node requires CAP_MKNOD (root); making
* a FIFO works unprivileged (mkfifo). When the receiver lacks the capability,
* mknodat() fails with EPERM and the entry is SKIPPED with a warning -- the
* whole transfer must NOT abort just because the environment cannot make the
* node. CI runs non-root, so device creation is expected to skip there and
* only a FIFO is honestly assertable unprivileged.
*
* Confinement: the parent directory is opened fd-relative below the receive
* root (file_open_secure_parent: O_NOFOLLOW, no "..", root-checked) and the
* node is created with mknodat()/mkfifoat(), so it can never be placed outside
* the confined root and never follows a symlink.
*
* rdev validation: a malicious/bogus rdev (negative, out-of-range) is rejected
* here as well as on the wire (file_receive_special / chunk_deserialize), and a
* non-device entry must carry an empty rdev.
*/
static FileSaveResult file_save_special_to_disk(const char* root_directory, const File* file,
const Config* config) {
/* The empty-path and structural checks stay unconditional; the redundant
".." list-path re-check is skipped under --trust-sender exactly like the
receive layer (confinement is deferred to the secure parent walk below,
which is never disabled). */
if (!root_directory || !file || !file->path || file->path[0] == '\0' ||
(!file_get_trust_sender() && has_path_traversal(file->path)) || !file->metadata)
return FILE_SAVE_ERROR;
mode_t mode = file->metadata->mode;
bool is_char = S_ISCHR(mode);
bool is_blk = S_ISBLK(mode);
bool is_fifo = S_ISFIFO(mode);
bool is_sock = S_ISSOCK(mode);
if (!is_char && !is_blk && !is_fifo && !is_sock) {
log_message(LOG_LEVEL_ERROR, "Special node has no device/FIFO/socket mode");
return FILE_SAVE_ERROR;
}
if (is_char || is_blk) {
if (!config || !config->preserve_devices)
return FILE_SAVE_SKIPPED;
/* --super / --no-super (P7 Wave E): char/block device-node creation is a
super-user activity. --no-super forbids it even for a root receiver;
AUTO and --super attempt it (an unprivileged attempt is refused by the
kernel and skipped). The helper is evaluated against THIS config's mode
so the policy does not depend on a prior identity_set_active(). Pure
FIFO creation is unprivileged and deliberately NOT gated here. */
if (!privilege_super_mode_permitted(config->super_mode)) {
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING,
"skipping %s: super-user device-node creation is not permitted on this receiver",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
return FILE_SAVE_SKIPPED;
}
} else if (is_fifo || is_sock) {
/* FIFOs and unix sockets are recreated by --specials. mknod(S_IFSOCK)
works unprivileged on Linux (the node carries no live socket), so unlike
a socket bound to a live fd it can be materialized. */
if (!config || !config->preserve_specials)
return FILE_SAVE_SKIPPED;
}
/* Defense-in-depth rdev/type validation (also done on the wire path). */
if (!file_special_rdev_valid(file->rdev_major, file->rdev_minor, mode)) {
log_message(LOG_LEVEL_ERROR, "Rejected out-of-range device rdev %d:%d", file->rdev_major,
file->rdev_minor);
return FILE_SAVE_ERROR;
}
char* destination = path_cat(root_directory, file->path);
if (!destination)
return FILE_SAVE_ERROR;
char* leaf = NULL;
int parent_fd = file_open_secure_parent(destination, &leaf, true);
if (parent_fd < 0) {
free(destination);
return FILE_SAVE_ERROR;
}
/* --existing / --ignore-existing / --update decide against the node that
would be replaced, mirroring the regular-file path. */
if (config->existing && !file_path_exists_secure(destination)) {
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_SKIPPED;
}
if (config->ignore_existing && file_path_exists_secure(destination)) {
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_SKIPPED;
}
if (config->update && file_destination_is_newer_secure(destination, file->metadata)) {
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_SKIPPED;
}
dev_t rdev = 0;
mode_t create_mode;
if (is_char) {
create_mode = S_IFCHR;
rdev = makedev((unsigned)file->rdev_major, (unsigned)file->rdev_minor);
} else if (is_blk) {
create_mode = S_IFBLK;
rdev = makedev((unsigned)file->rdev_major, (unsigned)file->rdev_minor);
} else if (is_sock) {
create_mode = S_IFSOCK;
} else {
create_mode = S_IFIFO;
}
const char* node_kind = (is_char || is_blk) ? "device" : (is_fifo ? "FIFO" : "socket");
/* Under -p/--perms rsync copies the source's permission and special bits; a
* kernel that denies setuid/setgid/sticky reports the failure rather than
* having them masked here. Without -p the node is created like any other new
* entry: source_mode & 0777 & ~umask. */
mode_t perms = config->preserve_perms ? (mode & (mode_t)(S_ISUID | S_ISGID | S_ISVTX | 0777))
: (mode & 0777 & ~(mode_t)file_process_umask());
int rc = is_fifo ? mkfifoat(parent_fd, leaf, perms)
: mknodat(parent_fd, leaf, create_mode | perms, rdev);
if (rc != 0) {
if (errno == EEXIST) {
/* An entry already exists: only skip when it already is a matching node;
never replace an existing directory or unrelated entry with the node. */
struct stat st;
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) == 0 &&
((is_char && S_ISCHR(st.st_mode)) || (is_blk && S_ISBLK(st.st_mode)) ||
(is_fifo && S_ISFIFO(st.st_mode)) || (is_sock && S_ISSOCK(st.st_mode)))) {
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_SKIPPED;
}
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "refusing to replace existing entry with %s: %s (skipped)",
node_kind, escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
} else if (errno == EPERM || errno == EACCES) {
/* Missing CAP_MKNOD / parent write permission: the environment cannot
create the node, so skip instead of failing the whole run. */
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING,
"skipping %s: cannot create %s node (%s)\n"
" --devices/--specials node creation needs privilege (CAP_MKNOD)",
escaped_path ? escaped_path : "<allocation failed>", node_kind, strerror(errno));
free(escaped_path);
} else {
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "failed to create %s %s: %s (skipped)", node_kind,
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
free(escaped_path);
}
close(parent_fd);
free(leaf);
free(destination);
return FILE_SAVE_SKIPPED;
}
/* Apply times on the fresh node (utimensat, no-follow) per the negotiated
* per-attribute policy: mtime only under -t, atime only under -U. The slot
* not requested stays UTIME_OMIT so it is left untouched. */
FileAttrPolicy policy = file_attr_policy_from_config(config);
if (policy.times || (policy.atimes && file->metadata->atime_valid)) {
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = 0, .tv_nsec = UTIME_OMIT}};
if (policy.times) {
times[1].tv_sec = file->metadata->mtime_sec;
times[1].tv_nsec = file->metadata->mtime_nsec;
}
if (policy.atimes && file->metadata->atime_valid) {
times[0].tv_sec = file->metadata->atime_sec;
times[0].tv_nsec = file->metadata->atime_nsec;
}
utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW);
}
/* P7 Wave E: apply the negotiated ownership to the node ITSELF. A FIFO is
created unprivileged, but --copy-as and explicit identity policies own
every entry (a char/block node path is already privilege-gated above). The
no-follow helper changes the node's own ownership without dereferencing it;
it is a no-op unless an identity policy is active. */
bool owner_ok = true;
if (identity_active_enabled())
owner_ok = identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
(int32_t)file->metadata->gid);
close(parent_fd);
free(leaf);
free(destination);
/* A failed required --copy-as ownership marks the node as failed; every other
* identity policy stays best-effort. */
return owner_ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
}
/* --write-devices (receiver): write the received data directly into an EXISTING
* device node on the destination instead of creating a regular file. The node
* must already exist and be a char/block device (the device itself is opened and
* followed); it is confined to the receive root via file_open_secure_parent.
* Dangerous by nature, so deliberately restricted: a missing/non-device
* destination, or a write failure, is SKIPPED with a warning rather than
* allowed. On environments without device access the run still succeeds (the
* entry is skipped), never aborts. */
static FileSaveResult file_save_write_device(const char* root_directory, const File* file) {
if (!root_directory || !file || !file->path || file->path[0] == '\0' ||
(!file_get_trust_sender() && has_path_traversal(file->path)))
return FILE_SAVE_ERROR;
if (!file->data)
return FILE_SAVE_ERROR;
char* destination = path_cat(root_directory, file->path);
if (!destination)
return FILE_SAVE_ERROR;
char* leaf = NULL;
int parent_fd = file_open_secure_parent(destination, &leaf, false);
if (parent_fd < 0) {
free(destination);
return FILE_SAVE_SKIPPED;
}
/* O_NONBLOCK: a pre-existing FIFO at the target would otherwise block the
receive thread forever on open(2). With it the open only succeeds for a
readerless FIFO with O_RDWR (which the device fstat gate rejects anyway)
or fails with ENXIO/EAGAIN, both treated as a normal skip below. */
int fd = openat(parent_fd, leaf, O_WRONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
int saved_errno = errno;
free(leaf);
close(parent_fd);
if (fd < 0) {
free(destination);
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
const char* shown_path = escaped_path ? escaped_path : "<allocation failed>";
if (saved_errno == ENXIO || saved_errno == EAGAIN) {
/* A FIFO with no reader / an unreadable special: skip like every other
unusable write-devices target instead of blocking or failing. */
log_message(LOG_LEVEL_WARNING, "write-devices: %s not writable (%s); skipped", shown_path,
strerror(saved_errno));
} else {
log_message(LOG_LEVEL_WARNING, "write-devices: cannot open %s (%s); skipped", shown_path,
strerror(saved_errno));
}
free(escaped_path);
return FILE_SAVE_SKIPPED;
}
struct stat st;
if (fstat(fd, &st) != 0 || !(S_ISCHR(st.st_mode) || S_ISBLK(st.st_mode))) {
close(fd);
free(destination);
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "write-devices: %s is not a device node; skipped",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
return FILE_SAVE_SKIPPED;
}
bool ok = true;
if (file->data->size > 0) {
size_t total = (size_t)file->data->size;
size_t written = 0;
while (written < total) {
ssize_t n = write(fd, (char*)file->data->data + written, total - written);
if (n <= 0) {
ok = false;
break;
}
written += (size_t)n;
}
}
close(fd);
free(destination);
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_SKIPPED;
}
FileSaveResult file_save_to_disk_full(const char* root_directory, const File* file,
const Config* config) {
/* Central no-mutation guard: a server-contacting --dry-run (or a local batch
apply that somehow carries dry_run) must never touch the destination, no
matter which caller reached this primitive. The per-caller guards remain,
but this is the last line of defense for every save path. Report SKIPPED
so a --remove-source-files sender correctly keeps its source. */
if (config && config->dry_run)
return FILE_SAVE_SKIPPED;
/* Backups are incompatible with ignore-existing: moving the entry first
would make a concurrent no-replace commit overwrite its old name. */
bool backup_enabled = config && config->backup && !config->ignore_existing;
bool inplace = config && config->inplace;
bool sparse = config && config->preserve_sparse;
FileAttrPolicy policy = file_attr_policy_from_config(config);
const char* backup_suffix = (config && config->suffix) ? config->suffix : "~";
const char* backup_dir = (config && config->backup_dir) ? config->backup_dir : NULL;
const char* partial_dir = (config && config->partial_dir) ? config->partial_dir : NULL;
const char* temp_dir = (config && config->temp_dir) ? config->temp_dir : NULL;
bool use_partial_root = partial_dir && config && config->partial;
char *confined_backup = NULL, *confined_partial = NULL, *disk_path = NULL;
char* destination_path = NULL;
char *backup_path = NULL, *parent_copy = NULL;
if (!file || !file->path || !file->data || (file->data->size != 0 && !file->data->data) ||
(!file_get_trust_sender() && has_path_traversal(file->path)) ||
(backup_enabled &&
(!backup_suffix || backup_suffix[0] == '\0' || strchr(backup_suffix, '/') != NULL ||
strcmp(backup_suffix, ".") == 0 || strcmp(backup_suffix, "..") == 0))) {
log_message(LOG_LEVEL_ERROR, "Invalid file or path received");
return FILE_SAVE_ERROR;
}
/* P7 Wave D #1: a STATUS_DIR_TIMES entry is RECORD-ONLY. The scanner
captures every traversed directory -- including empty ones whose parents
were never created by a child write and directories pruned by
-m/--prune-empty-dirs. Creating them here would resurrect empty
directories (an -a behavior change) and could abort the whole transfer on a
pre-existing regular file/symlink at the mirror path. Short-circuit before
any device/write-devices/directory branch and report it as skipped so the
sink still accumulates its metadata for the deferred DirTimeList
application, but create nothing. */
if (file->dir_time_only)
return FILE_SAVE_SKIPPED;
/* Device/special node (--devices/--specials): recreate the node instead of
writing content (privilege-gated, confined, rdev-validated). */
if (file->is_special)
return file_save_special_to_disk(root_directory, file, config);
/* --write-devices: write straight into an existing device node. Writing
into a device is a super-user activity, so --no-super must suppress it just
like device-node creation; the default AUTO/--super attempt it (the wide
open below keeps its own confinement and best-effort skip semantics). */
if (config && config->write_devices) {
if (!privilege_super_mode_permitted(config->super_mode)) {
char* escaped_path = output_escape(file->path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING,
"write-devices: %s skipped: super-user activities are not permitted on this "
"receiver",
escaped_path ? escaped_path : "(null)");
free(escaped_path);
return FILE_SAVE_SKIPPED;
}
return file_save_write_device(root_directory, file);
}
/* Explicit directory entries (--dirs) carry an empty payload; the entry is
created as a directory under the receive root, applying the same secure
mkdir-parent semantics as regular writes. Directories are created
immediately (they are never staged by --delay-updates, matching rsync,
where directory creation is not delayed). */
if (file->is_dir) {
if (file->path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(file->path))) {
log_message(LOG_LEVEL_ERROR, "Invalid directory path received");
return FILE_SAVE_ERROR;
}
char* dir_path = path_cat(root_directory, file->path);
if (!dir_path)
return FILE_SAVE_ERROR;
bool ok = file_ensure_directory_secure(dir_path);
/* P7 Wave E: apply the negotiated ownership to the directory ITSELF (not
just the files inside it). --copy-as and every explicit identity policy
own every entry, so a directory must not keep the receiver's owner while
its children get the policy owner. Applied no-follow on the confined
parent fd after the mkdir; identity_apply_ownership_link() is itself a
no-op unless an identity policy is active. */
if (ok && file->metadata && identity_active_enabled()) {
char* leaf = NULL;
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
if (parent_fd >= 0) {
if (!identity_apply_ownership_link(parent_fd, leaf, (int32_t)file->metadata->uid,
(int32_t)file->metadata->gid))
ok = false;
close(parent_fd);
} else if (identity_copy_as_active()) {
/* The directory exists (ok) but its required --copy-as ownership could
not be applied because the confined parent could not be opened. */
ok = false;
}
free(leaf);
}
free(dir_path);
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
}
/* Symlink entry. (The process-wide --keep-dirlinks policy is set once by the
connection handler from the negotiated config, before any receiver/writer
threads start, so it is stable throughout this walk.) */
if (file->is_symlink) {
if (!file->symlink_target || file->path[0] == '\0' ||
(!file_get_trust_sender() && has_path_traversal(file->path))) {
log_message(LOG_LEVEL_ERROR, "Invalid symlink entry received");
return FILE_SAVE_ERROR;
}
char* link_path = path_cat(root_directory, file->path);
if (!link_path)
return FILE_SAVE_ERROR;
/* The link value is stored verbatim (rsync -l parity: absolute and
".."-bearing targets are preserved; the scanner's --safe-links /
--copy-unsafe-links decide which links are sent at all). --munge-links
is a RECEIVER-side rewrite: the stored target is prefixed with
/rsyncd-munged/, making the link unusable while the referenced directory
does not exist -- exactly as rsync's receiver munges. Only the link's
own placement path is confined below the receive root. */
bool munge = config && config->munge_links;
char* target = str_dup(file->symlink_target);
bool ok = target != NULL;
if (ok && munge) {
char* munged = file_symlink_munge(target);
free(target);
target = munged;
ok = target != NULL;
}
if (!ok) {
free(target);
free(link_path);
return FILE_SAVE_SKIPPED;
}
char* parent = str_dup(link_path);
if (parent) {
/* Propagate a failed --copy-as ownership of the parent directory this
creates; every other failure mode stays best-effort as before. */
ok = file_ensure_directory_secure(dirname(parent));
free(parent);
}
if (ok)
ok = file_symlink_at_secure(link_path, target);
free(target);
/* P7 Wave D: apply the symlink's own metadata with no-follow primitives
(utimensat/lchown/fchmodat AT_SYMLINK_NOFOLLOW). -J/--omit-link-times
suppresses the timestamps; ownership stays gated by the identity policy.
A symlink has no children, so this can be applied immediately. */
if (ok && config && config->use_metadata) {
FileAttrPolicy link_policy = file_attr_policy_from_config(config);
ok = file_restore_symlink_metadata(link_path, file->metadata, link_policy,
config->omit_link_times);
}
free(link_path);
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
}
/* --hard-links/-H sibling: a later member of a link group arrives with no
payload and is installed as a hard link to (or, on link() failure, a
byte-identical copy of) the group's first member. Handled entirely here,
before the normal data-write paths (which would create an empty file). */
if (file->link_group != 0 && !file->link_first && file->hardlink_target != NULL) {
return file_save_hardlink_sibling(root_directory, file, config);
}
/* These options arrive from the client. --backup-dir, --partial-dir and
--temp-dir are names below the server root, never independent filesystem
roots: an absolute or `..`-escaping value is rejected outright (rsync's
daemon confines temp-dir to the module the same way). A relative temp dir
is resolved under the receive root below; if that resolution still lands on
a different filesystem than the destination the install falls back to a
non-atomic copy (see file_to_disk_secure_impl), never an abort. */
if ((backup_dir && (backup_dir[0] == '/' || has_path_traversal(backup_dir))) ||
(partial_dir && (partial_dir[0] == '/' || has_path_traversal(partial_dir))) ||
(temp_dir && (temp_dir[0] == '/' || has_path_traversal(temp_dir))))
return FILE_SAVE_ERROR;
if (backup_dir && !(confined_backup = path_cat(root_directory, backup_dir)))
return FILE_SAVE_ERROR;
if (partial_dir && !(confined_partial = path_cat(root_directory, partial_dir))) {
free(confined_backup);
return FILE_SAVE_ERROR;
}
const char* actual_root = use_partial_root ? confined_partial : root_directory;
destination_path = path_cat(root_directory, file->path);
disk_path = path_cat(actual_root, file->path);
if (destination_path == NULL || disk_path == NULL) {
free(confined_backup);
free(confined_partial);
free(destination_path);
free(disk_path);
return FILE_SAVE_ERROR;
}
/* --delay-updates diverts the whole write into the staging tree; the rest of
this function is the immediate-install path. */
if (config && config->delay_updates) {
FileSaveResult result =
file_stage_delayed_update(root_directory, destination_path, file, (Config*)config);
free(confined_backup);
free(confined_partial);
free(destination_path);
free(disk_path);
return result;
}
/* --existing checks the final destination, not a temporary partial path. */
if (config && config->existing && !file_path_exists_secure(destination_path)) {
free(confined_backup);
free(confined_partial);
free(destination_path);
free(disk_path);
return FILE_SAVE_SKIPPED;
}
/* --ignore-existing checks the final destination before partial files or
overwrite policies can modify it. */
if (config && config->ignore_existing) {
bool exists = file_path_exists_secure(destination_path);
if (exists) {
free(confined_backup);
free(confined_partial);
free(destination_path);
free(disk_path);
return FILE_SAVE_SKIPPED;
}
}
/* --update is receiver-side policy: never replace a newer destination.
In partial-dir mode the entry that would be replaced is the real
destination, not the temporary partial file. The secure stat does not
require read permission on the destination. */
const char* update_target = use_partial_root ? destination_path : disk_path;
if (config && config->update && file_destination_is_newer_secure(update_target, file->metadata)) {
free(confined_backup);
free(confined_partial);
free(destination_path);
free(disk_path);
return FILE_SAVE_SKIPPED;
}
/* --force (rsync semantics): an incoming regular file may replace a
destination DIRECTORY by removing that (possibly non-empty, symlink-safe)
tree first, so the atomic temp+rename below can install the file. Only the
immediate-install path does this: a --delay-updates run stages into its own
tree and is unaffected here (its publication renames over regular files
only). The blocking directory is removed only after the --update /
--existing / --ignore-existing decisions above, which see it as an existing
destination entry. */
if (config && config->force_delete && !file->is_dir &&
file_directory_exists_secure(destination_path)) {
if (!file_remove_tree_secure(destination_path))
goto fail;
}
if (backup_enabled) {
/* Back up the entry that the incoming write will replace. When writing
through a partial dir the pre-existing destination file is the one to
preserve; any stale partial file is overwritten without a backup. */
const char* replace_target = use_partial_root ? destination_path : disk_path;
struct stat backup_stat;
if (file_stat_secure(replace_target, &backup_stat)) {
if (backup_dir) {
backup_path = path_cat(confined_backup, file->path);
} else {
size_t path_len = strlen(replace_target);
size_t suffix_len = strlen(backup_suffix);
if (path_len > SIZE_MAX - suffix_len - 1)
goto fail;
backup_path = malloc(path_len + suffix_len + 1);
if (backup_path) {
memcpy(backup_path, replace_target, path_len);
memcpy(backup_path + path_len, backup_suffix, suffix_len + 1);
}
}
if (!backup_path)
goto fail;
parent_copy = str_dup(backup_path);
if (!parent_copy || !file_ensure_directory_secure(dirname(parent_copy)))
goto fail;
free(parent_copy);
parent_copy = NULL;
if (!file_rename_secure(replace_target, backup_path))
goto fail;
free(backup_path);
backup_path = NULL;
}
}
FileMetadata adjusted_metadata;
const FileMetadata* metadata = file->metadata;
if (metadata && config && config->chmod_spec && *config->chmod_spec) {
adjusted_metadata = *metadata;
if (!chmod_apply(adjusted_metadata.mode, config->chmod_spec, &adjusted_metadata.mode))
goto fail;
metadata = &adjusted_metadata;
}
/* A configured --temp-dir sends the temporary working copy to a scratch
directory; the engine then atomically renames the completed file into the
final destination directory. A relative temp dir is resolved under the
receive root and must already exist (an absolute or `..`-escaping value was
rejected above); the engine falls back to a non-atomic copy on EXDEV. The
partial-dir flow already keeps its working copy in a separate directory and
--inplace writes directly, so neither diverts through the scratch dir
(matching rsync, where --inplace/--partial-dir supersede --temp-dir). */
char* confined_temp = NULL;
bool use_temp_dir = temp_dir != NULL && !inplace && !use_partial_root;
if (use_temp_dir) {
confined_temp = path_cat(root_directory, temp_dir);
if (!confined_temp)
goto fail;
/* A user-supplied trailing slash would leave the scratch path ending in
"/", which has no final component to create/open. Normalize it away. */
size_t temp_len = strlen(confined_temp);
while (temp_len > 1 && confined_temp[temp_len - 1] == '/')
confined_temp[--temp_len] = '\0';
}
/* A --link-dest basis hit installs an atomic hard link (with a byte-copy
fallback); --inplace and the update/no-replace write variants do not
apply to a fresh hard link, whose inode attributes already match. The
existing/ignore-existing/update/backup preamble above has already made the
policy decision. */
bool ok;
if (config && file->basis_link) {
ok = file_to_disk_secure_link_attrs(
disk_path, file->basis_link, file->data->data, file->data->size, config->preallocate,
metadata, policy, config->use_fsync, file->xattrs, config->fake_super, confined_temp);
} else {
/* The plain no-replace / update / with-fsync engines, plus per-file xattr
(-X/-A) and --fake-super application on the written fd. */
ok = file_to_disk_secure_attrs(
disk_path, file->data->data, file->data->size, inplace, sparse,
config && config->preallocate, metadata, policy, config && config->update,
config && config->ignore_existing, config && config->use_fsync, file->xattrs,
config ? config->fake_super : false, config ? config->partial : false, confined_temp);
}
free(confined_temp);
confined_temp = NULL;
if (!ok)
goto fail;
/* --partial --partial-dir writes the complete file under the partial dir so
interrupted transfers leave a resumable copy there. Once the file is
fully written it must be atomically installed at the real destination;
otherwise completed transfers would linger under the partial dir. */
if (use_partial_root) {
if (!file_rename_secure(disk_path, destination_path))
goto fail;
}
free(parent_copy);
free(backup_path);
free(confined_backup);
free(confined_partial);
free(destination_path);
free(disk_path);
return FILE_SAVE_WRITTEN;
fail:
free(parent_copy);
free(backup_path);
free(confined_backup);
free(confined_partial);
free(destination_path);
free(disk_path);
return FILE_SAVE_ERROR;
}
/* Receive a file's xattr block (when the config enables xattr transport) and
* attach it to `file`. Returns false on a malformed/oversized frame. */
static bool receive_file_xattrs(File* file, int fd, const Config* config) {
if (!config->use_xattrs)
return true;
int xok = 0;
FileXattrList* list = xattr_receive(fd, &xok, config->preserve_acls);
if (!xok) {
xattr_list_free(list);
return false;
}
file->xattrs = list;
return true;
}
static File* receive_delta_file(int fd, const Config* config, const char* check_path,
void* old_data, unsigned long long old_size, bool* failed) {
if (!old_data) {
free(old_data); /* defensive: old_data is always non-NULL today */
*failed = true;
return NULL;
}
DeltaSignature* sig = delta_signature_create_seeded(old_data, old_size, config->delta_block_size,
(uint32_t)config->checksum_seed);
if (!sig) {
free(old_data);
*failed = true;
return NULL;
}
Data* sig_data = delta_signature_serialize(sig);
if (!sig_data) {
delta_signature_destroy(sig);
free(old_data);
*failed = true;
return NULL;
}
bool sig_sent = send_status(fd, STATUS_DELTA_SIGNATURE) && send_data(fd, sig_data);
data_destroy(sig_data);
if (!sig_sent) {
delta_signature_destroy(sig);
free(old_data);
*failed = true;
return NULL;
}
Status resp;
if (!receive_status(fd, &resp)) {
delta_signature_destroy(sig);
free(old_data);
*failed = true;
return NULL;
}
if (resp == STATUS_DELTA_DATA) {
Data* delta_data = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE);
if (!delta_data) {
delta_signature_destroy(sig);
free(old_data);
*failed = true;
return NULL;
}
Data* raw_delta = delta_data;
if (config->use_compression &&
!compression_should_skip_with_suffixes(
check_path, config->skip_compress_suffixes,
config->skip_compress_set ? config->skip_compress_count : -1)) {
ProtocolSession* owner = delta_data->owner;
raw_delta = data_decompress_limited(delta_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
data_destroy(delta_data);
if (!raw_delta) {
free(old_data);
delta_signature_destroy(sig);
*failed = true;
return NULL;
}
/* Charge the decompressed delta to the connection budget (the paired
wire buffer's charge was just released). */
if (!data_charge_session(raw_delta, owner, raw_delta->size)) {
data_destroy(raw_delta);
free(old_data);
delta_signature_destroy(sig);
*failed = true;
return NULL;
}
}
Delta* delta = delta_deserialize(raw_delta);
data_destroy(raw_delta);
if (!delta) {
free(old_data);
delta_signature_destroy(sig);
*failed = true;
return NULL;
}
uint64_t new_size = delta->new_file_size;
if (new_size > MAX_RECEIVE_WHOLE_FILE_SIZE || new_size > SIZE_MAX) {
delta_destroy(delta);
free(old_data);
delta_signature_destroy(sig);
send_status(fd, STATUS_ERROR);
*failed = true;
return NULL;
}
void* new_data = delta_apply(old_data, old_size, delta, config->delta_block_size);
delta_destroy(delta);
if (!new_data) {
free(old_data);
delta_signature_destroy(sig);
*failed = true;
return NULL;
}
File* file = file_create(check_path);
if (!file) {
free(new_data);
free(old_data);
delta_signature_destroy(sig);
*failed = true;
return NULL;
}
if (config->use_metadata) {
int meta_ok = 1;
file->metadata = metadata_receive(fd, &meta_ok);
if (!meta_ok) {
file_destroy(file);
free(new_data);
free(old_data);
delta_signature_destroy(sig);
*failed = true;
return NULL;
}
}
if (!receive_file_xattrs(file, fd, config)) {
file_destroy(file);
free(new_data);
free(old_data);
delta_signature_destroy(sig);
*failed = true;
return NULL;
}
Data* replacement = data_create(new_data, (size_t)new_size);
if (replacement == NULL) {
file_destroy(file);
free(old_data);
delta_signature_destroy(sig);
send_status(fd, STATUS_ERROR);
*failed = true;
return NULL;
}
data_destroy(file->data);
file->data = replacement;
free(old_data);
delta_signature_destroy(sig);
return file;
}
if (resp == STATUS_NEXT) {
delta_signature_destroy(sig);
free(old_data);
File* file = file_create(check_path);
if (!file) {
*failed = true;
return NULL;
}
if (config->use_metadata) {
int meta_ok = 1;
file->metadata = metadata_receive(fd, &meta_ok);
if (!meta_ok) {
file_destroy(file);
*failed = true;
return NULL;
}
}
if (!receive_file_xattrs(file, fd, config)) {
file_destroy(file);
*failed = true;
return NULL;
}
Data* file_data = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE);
if (file_data == NULL) {
file_destroy(file);
*failed = true;
return NULL;
}
if (config->use_compression &&
!compression_should_skip_with_suffixes(
file->path, config->skip_compress_suffixes,
config->skip_compress_set ? config->skip_compress_count : -1)) {
Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = file_data->owner;
data_destroy(file_data);
if (uncompressed == NULL) {
file_destroy(file);
*failed = true;
return NULL;
}
if (!data_charge_session(uncompressed, owner, uncompressed->size)) {
data_destroy(uncompressed);
file_destroy(file);
send_status(fd, STATUS_ERROR);
*failed = true;
return NULL;
}
if (uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(uncompressed);
file_destroy(file);
send_status(fd, STATUS_ERROR);
*failed = true;
return NULL;
}
file_data = uncompressed;
}
data_destroy(file->data);
file->data = file_data;
return file;
}
delta_signature_destroy(sig);
free(old_data);
send_status(fd, STATUS_ERROR);
*failed = true;
return NULL;
}
/* ---- Alternate basis directories (--compare-dest / --copy-dest / --link-dest) ----
* The receiver consults the ordered basis-dir list only when the destination
* entry is NOT already up to date. An "exact match" requires an equal size,
* an equal mtime (unless --size-only), and an equal content xxHash64, so a
* hard link / local copy is only ever made from byte-identical content. */
typedef struct BasisMatch {
bool hit;
BasisDestType type;
char* basis_path; /* owned absolute path of the matched basis file */
struct stat st; /* fstat() of the matched basis file */
Data* content; /* owned basis bytes (or empty Data), NULL when not loaded */
} BasisMatch;
static void basis_match_free(BasisMatch* match) {
if (!match)
return;
free(match->basis_path);
match->basis_path = NULL;
data_destroy(match->content);
match->content = NULL;
match->hit = false;
match->type = BASIS_DEST_NONE;
}
/* Open `path` (via the secure, root-confined primitives) and require it to be
a regular file of exactly `expected_size` bytes. Returns an open read-only
descriptor and its fstat on success. */
static bool basis_open_regular(const char* path, unsigned long long expected_size, int* out_fd,
struct stat* out_st) {
char* leaf = NULL;
int parent_fd = file_open_secure_parent(path, &leaf, false);
if (parent_fd < 0)
return false;
/* O_NONBLOCK: a client-planted FIFO must not block the receiver's openat()
forever; the fstat()/S_ISREG gate below rejects it immediately. */
int fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
free(leaf);
close(parent_fd);
if (fd < 0)
return false;
struct stat st;
if (fstat(fd, &st) != 0 || !S_ISREG(st.st_mode) ||
(unsigned long long)st.st_size != expected_size) {
close(fd);
return false;
}
*out_fd = fd;
*out_st = st;
return true;
}
/* Read the whole remaining content of an open descriptor. A zero-length file
yields an empty Data (data pointer NULL). */
static Data* basis_read_content(int fd, unsigned long long size) {
if (size == 0)
return data_create_reserve(0);
if (size > MAX_RECEIVE_WHOLE_FILE_SIZE || size > SIZE_MAX)
return NULL;
void* buf = protocol_alloc((size_t)size);
if (!buf)
return NULL;
size_t got = 0;
while (got < (size_t)size) {
ssize_t n = read(fd, (char*)buf + got, (size_t)size - got);
if (n <= 0) {
free(buf);
return NULL;
}
got += (size_t)n;
}
return data_create(buf, (size_t)size);
}
/* --ignore-times forces every file to be updated, so no basis hit is ever
declared (matching rsync, where -I prevents link-dest from linking). */
static bool basis_quick_matches(const Config* config, const struct stat* st, time_t check_mtime,
long check_mtime_nsec) {
if (config->size_only)
return true;
long mtime_nsec = 0;
#ifdef __linux__
mtime_nsec = st->st_mtim.tv_nsec;
#endif
return metadata_mtime_matches(st->st_mtime, mtime_nsec, check_mtime, check_mtime_nsec,
config->modify_window);
}
/* Search the basis-dir list in command-line order and return the first exact
match. When load_content is true the matched bytes are kept in out->content
so the caller can materialize the file without re-reading it.
An exact match ALSO requires the basis bytes' digest to equal the source's,
so `hash_content` gates the content read/hash itself. A server-contacting
--dry-run passes hash_content=false: no basis file may be read or hashed
(that would be a 1-bit content oracle against a client-supplied digest), so a
metadata-only pass can never confirm a hit and declines it. The real path
always passes hash_content=true, keeping its behavior byte-for-byte. */
static bool basis_match_find(const Config* config, const char* check_path,
unsigned long long check_size, time_t check_mtime,
long check_mtime_nsec, const uint8_t* check_digest,
size_t check_digest_len, bool load_content, bool hash_content,
BasisMatch* out) {
memset(out, 0, sizeof(*out));
if (!config || !config_has_basis(config) || config->ignore_times)
return false;
/* Dry-run: never read/hash basis content. A hit cannot be decided from
metadata alone, so report no match (the caller treats it as would-transfer)
without touching the file's contents. */
if (!hash_content)
return false;
for (int i = 0; i < config->basis_count; i++) {
const BasisDest* entry = &config->basis_dirs[i];
char* basis_dir = path_cat(config->receive_root_directory, entry->path);
if (!basis_dir)
continue;
char* candidate = path_cat(basis_dir, check_path);
free(basis_dir);
if (!candidate)
continue;
int fd;
struct stat st;
if (basis_open_regular(candidate, check_size, &fd, &st)) {
if (basis_quick_matches(config, &st, check_mtime, check_mtime_nsec)) {
Data* content = basis_read_content(fd, check_size);
if (content) {
uint8_t basis_digest[CHECKSUM_MAX_DIGEST_LEN];
size_t basis_len = 0;
bool hashed = checksum_digest((ChecksumAlgo)config->checksum_algo, config->checksum_seed,
content->data, content->size, basis_digest,
sizeof(basis_digest), &basis_len);
if (hashed && basis_len == check_digest_len && check_digest_len > 0 &&
memcmp(basis_digest, check_digest, check_digest_len) == 0) {
out->hit = true;
out->type = entry->type;
out->basis_path = candidate;
candidate = NULL; /* ownership transferred to out */
out->st = st;
out->content = load_content ? content : NULL;
if (!load_content)
data_destroy(content);
close(fd);
return true;
}
}
data_destroy(content);
}
close(fd);
}
free(candidate);
}
return false;
}
/* ---------------------------------------------------------------------------
* -y/--fuzzy similar-file delta basis.
*
* When a file must be transferred and the destination holds no usable content
* at the exact path (the destination file is absent, or is outside the delta
* engine's size bounds), --fuzzy lets the receiver reuse an EXISTING regular
* file in the SAME destination directory as the delta basis, so the sender
* transmits only the differences instead of the whole file. This is the
* rsync "find a similar file to use as a basis for a transfer" case (e.g. a
* file recreated under a new name whose old-named sibling is still present).
*
* The delta handshake is unchanged and receiver-driven, so the sender never
* learns the basis was a different file and needs no new protocol. Byte
* exactness never depends on which bytes the basis holds: the delta protocol
* only references basis blocks whose Adler-32 + xxHash32 checksums match the
* source, delta_apply validates every reference against the basis size, and a
* basis that shares nothing simply makes the sender reply STATUS_NEXT (full
* transfer). A fuzzy basis can therefore waste bandwidth but never corrupt a
* file.
*
* Similarity heuristic (deterministic, deliberately simpler than rsync's):
* * candidates are the target's sibling entries in its destination
* directory, opened through the confined root (file_open_secure_parent +
* openat O_NOFOLLOW, fstatat AT_SYMLINK_NOFOLLOW) -- symlinks are never
* followed and nothing outside the destination root is ever read;
* * dotfiles, directories, the target's own name, and the .fastsync-stage /
* temp scratch names are never candidates;
* * size gate = the delta engine's own bounds (delta_should_attempt: both
* files >= DELTA_MIN_FILE_SIZE, <= delta_max_file_size, ratio <= 10x),
* NOT rsync's ~1.5x size window;
* * name gate = Levenshtein edit distance between the basenames, accepted
* only when distance <= half the length of the longer basename;
* * the single best candidate (smallest distance; tie-break: size closest
* to the incoming file, then lexicographically smaller basename) is read
* and returned as the basis.
* ------------------------------------------------------------------------- */
/* A directory scan is linear in the number of entries; the fuzzy search stops
* after this many so a pathological huge directory cannot stall a transfer.
* The cap bounds the readdir() ITERATIONS, not the per-entry work: every
* entry that survives the (cheap) size and pre-name gates still runs an
* edit-distance DP, so the per-entry DP cost is separately bounded below by
* pre-pruning on the name length gap and the absent-character bound, and by
* trimming the common prefix/suffix before the DP runs on the middles only. */
#define FUZZY_MAX_DIRECTORY_SCAN 4096
/* Names longer than this never take part in fuzzy matching: the edit-distance
* DP below is O(len^2), so over-long names are bounded out of the search. */
#define FUZZY_NAME_LIMIT 192
typedef struct {
char name[FUZZY_NAME_LIMIT + 1];
unsigned long long size;
size_t distance;
unsigned long long size_gap;
} FuzzyCandidate;
/* Two-row DP scratch, allocated once per directory scan (not per candidate) so
* a 4096-entry directory never performs 4096 malloc/free pairs. */
typedef struct {
size_t* prev;
size_t* cur;
} FuzzyEditBuffer;
static bool fuzzy_edit_buffer_init(FuzzyEditBuffer* buf) {
buf->prev = malloc((FUZZY_NAME_LIMIT + 1) * sizeof(size_t));
buf->cur = malloc((FUZZY_NAME_LIMIT + 1) * sizeof(size_t));
if (!buf->prev || !buf->cur) {
free(buf->prev);
free(buf->cur);
buf->prev = NULL;
buf->cur = NULL;
return false;
}
return true;
}
static void fuzzy_edit_buffer_destroy(FuzzyEditBuffer* buf) {
free(buf->prev);
free(buf->cur);
buf->prev = NULL;
buf->cur = NULL;
}
/* Cheap lower bounds used to reject a candidate BEFORE the DP:
* - any edit script must at least absorb the length gap: d >= |la - lb|;
* - any character of `a` that does not occur in `b` at all must be deleted or
* substituted at its own position: d >= (count of such characters).
* The acceptance gate is d*2 <= longer, so a candidate whose max of these two
* bounds already violates it can be skipped without computing the distance. */
static size_t fuzzy_absent_char_bound(const char* a, size_t la, const char* b, size_t lb) {
if (lb == 0)
return la;
bool present[256] = {false};
for (size_t i = 0; i < lb; i++)
present[(uint8_t)b[i]] = true;
size_t absent = 0;
for (size_t i = 0; i < la; i++)
if (!present[(uint8_t)a[i]])
absent++;
return absent;
}
/* Levenshtein edit distance between the two basenames. A shared prefix and a
* (non-overlapping) shared suffix can always be aligned at no cost, so the DP
* only runs over the differing middles; its two rows come from `buf` (allocated
* once by the caller). Callers enforce la, lb <= FUZZY_NAME_LIMIT. */
static size_t fuzzy_edit_distance(FuzzyEditBuffer* buf, const char* a, size_t la, const char* b,
size_t lb) {
size_t p = 0;
while (p < la && p < lb && a[p] == b[p])
p++;
/* Trim the common suffix (never overlapping the prefix). Working with two
moving end indices keeps the region arithmetic explicit and safe. */
size_t ae = la;
size_t be = lb;
while (ae > p && be > p && a[ae - 1] == b[be - 1]) {
ae--;
be--;
}
size_t ma = ae - p;
size_t mb = be - p;
/* cppcheck-suppress knownConditionTrueFalse -- the prefix/suffix trims above
only run while the corresponding ends match, so a middle can remain; the
analysis unsoundly concludes the trims always consume everything. */
if (ma == 0)
return mb;
if (mb == 0)
return ma;
const char* A = a + p;
const char* B = b + p;
size_t* prev = buf->prev;
size_t* cur = buf->cur;
for (size_t j = 0; j <= mb; j++)
prev[j] = j;
for (size_t i = 1; i <= ma; i++) {
cur[0] = i;
for (size_t j = 1; j <= mb; j++) {
size_t cost = A[i - 1] == B[j - 1] ? 0 : 1;
size_t del = prev[j] + 1;
size_t ins = cur[j - 1] + 1;
size_t sub = prev[j - 1] + cost;
size_t m = del < ins ? del : ins;
cur[j] = m < sub ? m : sub;
}
size_t* tmp = prev;
prev = cur;
cur = tmp;
}
return prev[mb];
}
/* Deterministic ordering of two fuzzy candidates: smallest edit distance,
* then the size closest to the incoming file, then the lexical basename. */
static bool fuzzy_candidate_better(const FuzzyCandidate* cand, const FuzzyCandidate* best) {
if (!best->name[0])
return true;
if (cand->distance != best->distance)
return cand->distance < best->distance;
if (cand->size_gap != best->size_gap)
return cand->size_gap < best->size_gap;
return strcmp(cand->name, best->name) < 0;
}
/* Search the destination directory that will contain `check_path` for a
* similar regular file usable as a --fuzzy delta basis and return its full
* content in a malloc'd (protocol_alloc) buffer. Returns NULL (with *out_size
* = 0) when no candidate qualifies, which means the caller performs the normal
* whole-file transfer. */
static void* fuzzy_basis_find_and_load(const Config* config, const char* check_path,
unsigned long long check_size,
unsigned long long* out_size) {
*out_size = 0;
if (!config || !config->receive_root_directory || !config->fuzzy || !config->use_delta ||
!check_path || check_size < DELTA_MIN_FILE_SIZE || check_size > config->delta_max_file_size ||
check_size > MAX_RECEIVE_WHOLE_FILE_SIZE)
return NULL;
char* full_path = path_cat(config->receive_root_directory, check_path);
if (!full_path)
return NULL;
char* leaf = NULL;
int dir_fd = file_open_secure_parent(full_path, &leaf, false);
if (dir_fd < 0 || !leaf) {
free(leaf);
free(full_path);
return NULL;
}
size_t target_len = strlen(leaf);
/* A target basename longer than FUZZY_NAME_LIMIT can never pass the name gate
(every candidate name is bounded by the same limit), so skip the scan. */
if (target_len > FUZZY_NAME_LIMIT) {
close(dir_fd);
free(leaf);
free(full_path);
return NULL;
}
int scanfd = dup(dir_fd);
if (scanfd < 0) {
close(dir_fd);
free(leaf);
free(full_path);
return NULL;
}
DIR* dir = fdopendir(scanfd);
if (!dir) {
close(scanfd);
close(dir_fd);
free(leaf);
free(full_path);
return NULL;
}
/* The DP scratch rows are allocated once per scan (not once per candidate). */
FuzzyEditBuffer ebuf;
if (!fuzzy_edit_buffer_init(&ebuf)) {
closedir(dir);
close(dir_fd);
free(leaf);
free(full_path);
return NULL;
}
FuzzyCandidate best;
memset(&best, 0, sizeof(best));
const struct dirent* entry;
size_t scanned = 0;
/* readdir() yields entries in filesystem-dependent order, so the SET of
candidates seen is order-dependent; the winner is still deterministic
because every candidate is compared with the total ordering in
fuzzy_candidate_better (acceptable for a heuristic). */
while (scanned < FUZZY_MAX_DIRECTORY_SCAN && (entry = readdir(dir)) != NULL) {
scanned++;
const char* name = entry->d_name;
size_t name_len = strlen(name);
if (name[0] == '.' || name_len == 0 || name_len > FUZZY_NAME_LIMIT || strcmp(name, leaf) == 0)
continue;
struct stat st;
if (fstatat(dir_fd, name, &st, AT_SYMLINK_NOFOLLOW) != 0 || !S_ISREG(st.st_mode))
continue;
unsigned long long cand_size = (unsigned long long)st.st_size;
if (cand_size == 0 || cand_size > MAX_RECEIVE_WHOLE_FILE_SIZE ||
!delta_should_attempt(cand_size, check_size, config->delta_max_file_size))
continue;
/* Cheap pre-name gates run BEFORE the edit-distance DP. The edit distance
is bounded below by the length gap |la-lb| and by the number of
characters of one basename that are absent from the other (each such
position costs at least one op), so a candidate whose acceptance gate
(distance*2 <= longer) already fails on the max of those bounds is
skipped without running the DP. */
size_t longer = target_len > name_len ? target_len : name_len;
size_t bound = longer - (target_len < name_len ? target_len : name_len);
size_t absent = fuzzy_absent_char_bound(leaf, target_len, name, name_len);
if (absent > bound)
bound = absent;
if (bound * 2 > longer)
continue;
size_t distance = fuzzy_edit_distance(&ebuf, leaf, target_len, name, name_len);
if (distance * 2 > longer)
continue;
FuzzyCandidate cand;
memcpy(cand.name, name, name_len + 1);
cand.size = cand_size;
cand.distance = distance;
cand.size_gap = cand_size > check_size ? cand_size - check_size : check_size - cand_size;
if (fuzzy_candidate_better(&cand, &best))
best = cand;
}
closedir(dir);
free(leaf);
fuzzy_edit_buffer_destroy(&ebuf);
void* basis = NULL;
if (best.name[0]) {
/* O_NONBLOCK: a name raced to a FIFO between the fstatat gate and this open
would otherwise block the receive thread forever on open(2); with it the
open fails (ENXIO) and the fstat/S_ISREG gate below would reject it too.
A regular file opened with O_NONBLOCK is unaffected. */
int fd = openat(dir_fd, best.name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
if (fd >= 0) {
struct stat st;
if (fstat(fd, &st) == 0 && S_ISREG(st.st_mode) &&
(unsigned long long)st.st_size == best.size && best.size <= SIZE_MAX) {
basis = protocol_alloc((size_t)best.size);
if (basis) {
size_t got = 0;
while (got < (size_t)best.size) {
ssize_t n = read(fd, (char*)basis + got, (size_t)best.size - got);
if (n <= 0) {
free(basis);
basis = NULL;
break;
}
got += (size_t)n;
}
}
}
close(fd);
}
}
close(dir_fd);
free(full_path);
if (basis)
*out_size = best.size;
return basis;
}
/* Read the remainder of a full-file transfer after the receiver has already
* sent STATUS_NEXT: receive the metadata frame (when enabled) followed by the
* data frame, and return an owned File. Shared by the plain full-transfer path
* and the --append-verify prefix-mismatch fallback (a clean full transfer
* instead of a corrupt prefix+tail blend). */
static File* receive_full_file(int fd, const Config* config, const char* path) {
File* file = file_create(path);
if (!file)
return NULL;
if (config->use_metadata) {
int meta_ok = 1;
file->metadata = metadata_receive(fd, &meta_ok);
if (!meta_ok) {
file_destroy(file);
return NULL;
}
}
if (!receive_file_xattrs(file, fd, config)) {
file_destroy(file);
return NULL;
}
Data* file_data = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE);
if (file_data == NULL) {
file_destroy(file);
return NULL;
}
if (config->use_compression &&
!compression_should_skip_with_suffixes(file->path, config->skip_compress_suffixes,
config->skip_compress_set ? config->skip_compress_count
: -1)) {
Data* uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = file_data->owner;
data_destroy(file_data);
if (uncompressed == NULL) {
file_destroy(file);
return NULL;
}
if (!data_charge_session(uncompressed, owner, uncompressed->size)) {
data_destroy(uncompressed);
file_destroy(file);
return NULL;
}
if (uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(uncompressed);
file_destroy(file);
return NULL;
}
file_data = uncompressed;
}
data_destroy(file->data);
file->data = file_data;
return file;
}
/* ---------------------------------------------------------------------------
* receive_incremental_check() decomposition.
*
* The per-file STATUS_CHECK fast path is split into the small helpers below,
* called in order by a short linear orchestrator (receive_incremental_check_ex).
* Each helper owns one decision: request validation, secure destination open,
* metadata-only skip, server-contacting --dry-run no-mutation short-circuit,
* alternate-basis match, --append tail resume, block delta, --fuzzy basis, and
* the final "send the whole file" fallback. Every protocol send/receive and
* every resource cleanup is preserved exactly; the non-dry-run wire is
* byte-for-byte unchanged. receive_incremental_check_ex additionally exposes a
* `would_transfer` out-param for the dry-run caller; the 3-arg
* receive_incremental_check wrapper passes NULL.
* ------------------------------------------------------------------------- */
/* Owned state threaded through the helpers below. */
typedef struct {
int fd;
const Config* config;
char* check_path; /* received destination-relative path */
char* full_path; /* receive-root-prefixed destination path */
unsigned long long check_size;
long long check_mtime;
long long check_mtime_nsec;
uint8_t check_digest[CHECKSUM_MAX_DIGEST_LEN];
size_t check_digest_len;
bool has_old_file;
int old_fd;
struct stat old_st;
unsigned long long old_size;
void* old_data; /* snapshot of the existing destination, or NULL */
} IncrementalCheckState;
typedef enum {
INCREMENTAL_CONTINUE, /* proceed to the next helper */
INCREMENTAL_ERROR, /* protocol/validation failure: return NULL */
INCREMENTAL_SKIP, /* up to date: *skipped = true, return NULL */
INCREMENTAL_DRY_RUN, /* --dry-run resolved: flags set, return NULL */
INCREMENTAL_FILE, /* a File* was produced (out_file) */
} IncrementalCheckOutcome;
static void incremental_check_state_init(IncrementalCheckState* state, int fd,
const Config* config) {
memset(state, 0, sizeof(*state));
state->fd = fd;
state->config = config;
state->old_fd = -1;
}
/* Release every resource the helpers may have acquired. Idempotent, so it is
safe on every exit path exactly the way the original inline cleanup was. */
static void incremental_check_state_cleanup(IncrementalCheckState* state) {
free(state->old_data);
state->old_data = NULL;
if (state->old_fd >= 0)
close(state->old_fd);
state->old_fd = -1;
free(state->full_path);
state->full_path = NULL;
free(state->check_path);
state->check_path = NULL;
}
/* Receive and validate the STATUS_CHECK request frame: path, size, mtime,
nanosecond mtime, and (when negotiated) the source digest. */
static IncrementalCheckOutcome incremental_check_receive_request(IncrementalCheckState* state) {
int fd = state->fd;
const Config* config = state->config;
char* check_path = receive_wire_str(fd);
if (check_path == NULL)
return INCREMENTAL_ERROR;
state->check_path = check_path;
if (!receive_n_data(fd, &state->check_size, sizeof(state->check_size)) ||
!receive_n_data(fd, &state->check_mtime, sizeof(state->check_mtime)))
return INCREMENTAL_ERROR;
if (!receive_n_data(fd, &state->check_mtime_nsec, sizeof(state->check_mtime_nsec)) ||
state->check_mtime_nsec < 0 || state->check_mtime_nsec >= 1000000000LL) {
send_error_detail(fd, "invalid check mtime nanoseconds");
return INCREMENTAL_ERROR;
}
if ((config->checksum || config_has_basis(config))) {
uint8_t wire_len;
if (!receive_n_data(fd, &wire_len, sizeof(wire_len)) || wire_len == 0 ||
wire_len > CHECKSUM_MAX_DIGEST_LEN ||
wire_len != checksum_digest_len((ChecksumAlgo)config->checksum_algo)) {
send_error_detail(fd, "invalid check digest length");
return INCREMENTAL_ERROR;
}
state->check_digest_len = wire_len;
if (!receive_n_data(fd, state->check_digest, state->check_digest_len))
return INCREMENTAL_ERROR;
}
if (state->check_size > MAX_RECEIVE_WHOLE_FILE_SIZE) {
send_error_detail(fd, "check size exceeds receiver limit");
return INCREMENTAL_ERROR;
}
if (check_path[0] == '\0' || has_path_traversal(check_path)) {
char* escaped_path = output_escape(check_path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received check path: %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
return INCREMENTAL_ERROR;
}
return INCREMENTAL_CONTINUE;
}
/* Open the existing destination entry once, confined below the receive root,
and record its stat. */
static IncrementalCheckOutcome incremental_check_open_destination(IncrementalCheckState* state) {
char* full_path = path_cat(state->config->receive_root_directory, state->check_path);
if (!full_path) {
send_error_detail(state->fd, "could not build destination path");
return INCREMENTAL_ERROR;
}
state->full_path = full_path;
char* leaf = NULL;
int parent_fd = file_open_secure_parent(full_path, &leaf, false);
if (parent_fd >= 0) {
/* O_NONBLOCK: an existing FIFO at the destination must not block this
openat(); the S_ISREG gate below rejects the non-regular entry. */
state->old_fd = openat(parent_fd, leaf, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK);
free(leaf);
close(parent_fd);
state->has_old_file = state->old_fd >= 0 && fstat(state->old_fd, &state->old_st) == 0 &&
S_ISREG(state->old_st.st_mode);
}
if (!state->has_old_file && state->old_fd >= 0) {
close(state->old_fd);
state->old_fd = -1;
}
state->old_size = state->has_old_file ? (unsigned long long)state->old_st.st_size : 0;
return INCREMENTAL_CONTINUE;
}
/* Output parity (protocol 2.23.0): when the wire config asked for it, report a
snapshot of the pre-transfer destination entry BEFORE the ordinary verdict so
the sender can render rsync-accurate -i/--out-format columns. A missing
destination is reported explicitly (existed=false) rather than omitted, so
the sender can distinguish "new" from "unknown". */
static IncrementalCheckOutcome incremental_check_report_dest_info(IncrementalCheckState* state) {
if (!state->config->report_dest_info)
return INCREMENTAL_CONTINUE;
OutputDestState info;
memset(&info, 0, sizeof(info));
info.known = true;
info.existed = state->has_old_file;
if (state->has_old_file) {
info.size = (unsigned long long)state->old_st.st_size;
info.mtime_sec = (long long)state->old_st.st_mtime;
#ifdef __linux__
info.mtime_nsec = state->old_st.st_mtim.tv_nsec;
#endif
info.mode = (uint32_t)state->old_st.st_mode;
info.uid = (int32_t)state->old_st.st_uid;
info.gid = (int32_t)state->old_st.st_gid;
}
if (!send_status(state->fd, STATUS_DEST_INFO) || !format_dest_state_send(state->fd, &info))
return INCREMENTAL_ERROR;
return INCREMENTAL_CONTINUE;
}
/* Metadata-only (and, when --checksum forces it, content) up-to-date decision.
Loads the old contents only when a checksum comparison or delta needs them. */
static IncrementalCheckOutcome incremental_check_quick_skip(IncrementalCheckState* state,
bool* out_try_delta) {
int fd = state->fd;
const Config* config = state->config;
bool has_old_file = state->has_old_file;
unsigned long long old_size = state->old_size;
struct stat st = state->old_st;
bool size_equal = has_old_file && old_size == state->check_size;
bool match_by_metadata = false;
if (size_equal && !config->ignore_times && !config->size_only) {
long long old_mtime_nsec = 0;
#ifdef __linux__
old_mtime_nsec = st.st_mtim.tv_nsec;
#endif
match_by_metadata =
metadata_mtime_matches(st.st_mtime, old_mtime_nsec, (time_t)state->check_mtime,
(long)state->check_mtime_nsec, config->modify_window);
}
bool try_delta = config->use_delta && !config->whole_file && has_old_file &&
delta_should_attempt(old_size, state->check_size, config->delta_max_file_size);
bool checksum_needs_read = size_equal && !config->ignore_times && config->checksum;
/* --dry-run must never read the destination file's CONTENTS: a client could
otherwise use `--dry-run --checksum` against a read-only module as a
1-bit content oracle (hash match / mismatch) and force arbitrary reads.
Decide from metadata alone; when metadata is inconclusive (checksum or
delta would have required the body) report would-transfer. The real
(non-dry-run) behavior below is unchanged. */
bool need_old_data = !config->dry_run && (checksum_needs_read || try_delta);
if (config->dry_run)
try_delta = false;
*out_try_delta = try_delta;
if (need_old_data && has_old_file && old_size > 0 && old_size <= MAX_RECEIVE_WHOLE_FILE_SIZE &&
old_size <= SIZE_MAX) {
state->old_data = protocol_alloc((size_t)old_size);
if (state->old_data) {
size_t got = 0;
while (got < (size_t)old_size) {
ssize_t n = read(state->old_fd, (char*)state->old_data + got, (size_t)old_size - got);
if (n <= 0) {
free(state->old_data);
state->old_data = NULL;
break;
}
got += (size_t)n;
}
}
}
bool match = false;
if (config->dry_run) {
/* Metadata-only decision: a size match plus a matching mtime is treated as
up to date; --checksum/--delta cannot be verified without reading, so an
otherwise inconclusive comparison is a would-transfer. */
match = size_equal && !config->ignore_times && (config->size_only || match_by_metadata);
} else if (checksum_needs_read) {
uint8_t old_digest[CHECKSUM_MAX_DIGEST_LEN];
size_t old_len = 0;
bool hashed = checksum_digest((ChecksumAlgo)config->checksum_algo, config->checksum_seed,
old_size == 0 ? "" : state->old_data, (size_t)old_size,
old_digest, sizeof(old_digest), &old_len);
match = hashed && old_len == state->check_digest_len && state->check_digest_len > 0 &&
memcmp(old_digest, state->check_digest, state->check_digest_len) == 0;
} else if (size_equal && !config->ignore_times) {
match = config->size_only || match_by_metadata;
}
if (match) {
if (!send_status(fd, STATUS_OK))
return INCREMENTAL_ERROR;
return INCREMENTAL_SKIP;
}
return INCREMENTAL_CONTINUE;
}
/* Server-contacting --dry-run no-mutation short-circuit. Runs after the
quick-skip decision and before any path that could touch the destination.
When dry_run is set and the file is not already up to date the receiver must
materialize nothing (no basis link/copy, no append/delta/full transfer) and
the sender must send no data, so answer STATUS_DRY_RUN_TRANSFER and stop.
The basis lookup is deliberately content-blind: a real run would only accept
a --compare-dest exact hit after hashing the basis file and comparing it with
the client-supplied digest, which in a dry-run is a 1-bit content oracle.
Under dry_run no basis bytes may be read, so an otherwise-matching entry is
treated as would-transfer instead of a skip. Everything read here (the
destination file's metadata, basis candidates' metadata) is read-only. */
static IncrementalCheckOutcome incremental_check_dry_run_shortcut(IncrementalCheckState* state,
bool* skipped,
bool* would_transfer) {
const Config* config = state->config;
if (!config->dry_run)
return INCREMENTAL_CONTINUE;
bool skip_via_compare = false;
if (config_has_basis(config) && !config->ignore_times) {
BasisMatch basis;
/* hash_content=false: a dry-run must not read or hash the basis file. No
content comparison is possible, so no compare-dest hit can be confirmed
and an otherwise-matching file is reported as would-transfer. */
basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime,
(long)state->check_mtime_nsec, state->check_digest, state->check_digest_len,
false, false, &basis);
if (basis.hit && basis.type == BASIS_DEST_COMPARE && !state->has_old_file)
skip_via_compare = true;
basis_match_free(&basis);
}
Status reply = skip_via_compare ? STATUS_OK : STATUS_DRY_RUN_TRANSFER;
if (!send_status(state->fd, reply))
return INCREMENTAL_ERROR;
if (skip_via_compare)
*skipped = true;
else if (would_transfer)
*would_transfer = true;
return INCREMENTAL_DRY_RUN;
}
/* Alternate basis directories (--compare-dest/--copy-dest/--link-dest): a hit
either suppresses the transfer (compare-dest) or materializes the file from
the basis without a data frame. */
static IncrementalCheckOutcome incremental_check_try_basis(IncrementalCheckState* state,
File** out_file) {
int fd = state->fd;
const Config* config = state->config;
if (!config_has_basis(config))
return INCREMENTAL_CONTINUE;
BasisMatch basis;
basis_match_find(config, state->check_path, state->check_size, (time_t)state->check_mtime,
(long)state->check_mtime_nsec, state->check_digest, state->check_digest_len,
true, true, &basis);
if (basis.hit) {
if (basis.type == BASIS_DEST_COMPARE) {
basis_match_free(&basis);
if (!state->has_old_file) {
if (!send_status(fd, STATUS_OK))
return INCREMENTAL_ERROR;
return INCREMENTAL_SKIP;
}
} else {
File* materialized = file_create(state->check_path);
if (materialized && basis.content) {
data_destroy(materialized->data);
materialized->data = basis.content;
basis.content = NULL;
materialized->metadata = file_metadata_create(NULL, &basis.st, false, false);
materialized->skip = true; /* receiver must not ack this as a data file */
if (basis.type == BASIS_DEST_LINK) {
materialized->basis_link = basis.basis_path;
basis.basis_path = NULL;
}
if (!materialized->metadata) {
file_destroy(materialized);
materialized = NULL;
}
} else {
file_destroy(materialized);
materialized = NULL;
}
if (materialized) {
if (!send_status(fd, STATUS_OK)) {
basis_match_free(&basis);
file_destroy(materialized);
return INCREMENTAL_ERROR;
}
basis_match_free(&basis);
*out_file = materialized;
return INCREMENTAL_FILE;
}
/* Materialization setup failed: fall through to the normal transfer. */
}
}
basis_match_free(&basis);
return INCREMENTAL_CONTINUE;
}
/* --append / --append-verify tail resume: when the destination is a SHORTER
file in an append mode, negotiate the resume offset and receive only the
tail. Produces the reconstructed file, or falls through to delta/full. */
static IncrementalCheckOutcome incremental_check_try_append_resume(IncrementalCheckState* state,
File** out_file) {
int fd = state->fd;
const Config* config = state->config;
const char* check_path = state->check_path;
unsigned long long old_size = state->old_size;
unsigned long long check_size = state->check_size;
bool append_resume = (config->append || config->append_verify) && state->has_old_file &&
append_resume_eligible(old_size, check_size);
if (!append_resume)
return INCREMENTAL_CONTINUE;
/* Ensure the retained prefix (== the whole, shorter destination file) is in
memory; it is needed both to rebuild the full file and, for
--append-verify, to checksum it. A load failure is not fatal: the resume is
simply not possible and we fall through to the other paths. */
if (state->old_data == NULL && old_size > 0 && old_size <= MAX_RECEIVE_WHOLE_FILE_SIZE &&
old_size <= SIZE_MAX) {
state->old_data = protocol_alloc((size_t)old_size);
if (state->old_data) {
size_t got = 0;
while (got < (size_t)old_size) {
ssize_t n = read(state->old_fd, (char*)state->old_data + got, (size_t)old_size - got);
if (n <= 0) {
free(state->old_data);
state->old_data = NULL;
break;
}
got += (size_t)n;
}
}
}
if (state->old_data == NULL && old_size != 0)
return INCREMENTAL_CONTINUE;
if (!send_status(fd, STATUS_APPEND) || !send_n_data(fd, &old_size, sizeof(old_size)))
return INCREMENTAL_ERROR;
bool verify = config->append_verify;
bool full_fallback = false;
if (verify) {
Status sig_status;
if (!receive_status(fd, &sig_status))
return INCREMENTAL_ERROR;
if (sig_status != STATUS_APPEND_SIG) {
send_status(fd, STATUS_ERROR);
return INCREMENTAL_ERROR;
}
uint64_t src_prefix_hash;
if (!receive_n_data(fd, &src_prefix_hash, sizeof(src_prefix_hash)))
return INCREMENTAL_ERROR;
/* Compare the retained prefix against the source prefix. A mismatch must
never be silently appended to: fall back to a full transfer so the result
is a byte-identical source copy. */
uint64_t dst_prefix_hash =
old_size == 0 ? delta_xxhash64("", 0) : delta_xxhash64(state->old_data, (size_t)old_size);
if (dst_prefix_hash == src_prefix_hash) {
if (!send_status(fd, STATUS_APPEND_OK))
return INCREMENTAL_ERROR;
} else {
if (!send_status(fd, STATUS_NEXT))
return INCREMENTAL_ERROR;
full_fallback = true;
}
}
if (full_fallback) {
/* Retained prefix differed: receive the sender's full transfer. */
free(state->old_data);
state->old_data = NULL;
if (state->old_fd >= 0) {
close(state->old_fd);
state->old_fd = -1;
}
*out_file = receive_full_file(fd, config, check_path);
return INCREMENTAL_FILE;
}
/* Receive the tail (STATUS_APPEND_DATA + metadata + tail bytes). */
Status tail_status;
if (!receive_status(fd, &tail_status))
return INCREMENTAL_ERROR;
if (tail_status != STATUS_APPEND_DATA) {
send_status(fd, STATUS_ERROR);
return INCREMENTAL_ERROR;
}
FileMetadata* meta = NULL;
FileXattrList* append_xattrs = NULL;
if (config->use_metadata) {
int meta_ok = 1;
meta = metadata_receive(fd, &meta_ok);
if (!meta_ok)
return INCREMENTAL_ERROR;
}
if (config->use_xattrs) {
int xok = 0;
append_xattrs = xattr_receive(fd, &xok, config->preserve_acls);
if (!xok) {
xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR;
}
}
Data* tail = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE);
if (tail == NULL) {
xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR;
}
if (config->use_compression &&
!compression_should_skip_with_suffixes(check_path, config->skip_compress_suffixes,
config->skip_compress_set ? config->skip_compress_count
: -1)) {
Data* uncompressed = data_decompress_limited(tail, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = tail->owner;
data_destroy(tail);
if (uncompressed == NULL) {
xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR;
}
if (!data_charge_session(uncompressed, owner, uncompressed->size)) {
data_destroy(uncompressed);
xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR;
}
if (uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(uncompressed);
xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR;
}
tail = uncompressed;
}
/* The tail must complete the file exactly; anything else is a protocol
violation (never a truncated or overrun file). */
unsigned long long expected_tail;
if (!append_tail_length(old_size, check_size, &expected_tail) ||
tail->size != (size_t)expected_tail) {
send_status(fd, STATUS_ERROR);
data_destroy(tail);
xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR;
}
size_t full_size = (size_t)check_size;
void* full = protocol_alloc(full_size ? full_size : 1);
if (!full) {
data_destroy(tail);
xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR;
}
if (old_size > 0 && state->old_data)
memcpy(full, state->old_data, (size_t)old_size);
if (tail->size > 0)
memcpy((char*)full + old_size, tail->data, tail->size);
data_destroy(tail);
free(state->old_data);
state->old_data = NULL;
File* file = file_create(check_path);
if (!file) {
free(full);
xattr_list_free(append_xattrs);
return INCREMENTAL_ERROR;
}
file->metadata = meta;
file->xattrs = append_xattrs;
append_xattrs = NULL;
data_destroy(file->data);
file->data = data_create(full, full_size);
if (!file->data) { /* data_create already freed full on failure */
file_destroy(file);
return INCREMENTAL_ERROR;
}
*out_file = file;
return INCREMENTAL_FILE;
}
/* Block delta transfer against the existing destination content. */
static IncrementalCheckOutcome incremental_check_try_delta(IncrementalCheckState* state,
bool try_delta, File** out_file) {
if (try_delta && state->old_data != NULL) {
bool delta_failed = false;
File* delta_file = receive_delta_file(state->fd, state->config, state->check_path,
state->old_data, state->old_size, &delta_failed);
state->old_data = NULL; /* receive_delta_file consumes the snapshot on every path */
if (delta_file) {
*out_file = delta_file;
return INCREMENTAL_FILE;
}
if (delta_failed)
return INCREMENTAL_ERROR;
}
free(state->old_data);
state->old_data = NULL;
return INCREMENTAL_CONTINUE;
}
/* -y/--fuzzy similar-file delta basis. Reaching this point means the file
must be transferred and the destination's own content could not serve as a
delta basis; try an existing similar-named sibling in the same directory. */
static IncrementalCheckOutcome incremental_check_try_fuzzy(IncrementalCheckState* state,
File** out_file) {
const Config* config = state->config;
if (!config->fuzzy || !config->use_delta)
return INCREMENTAL_CONTINUE;
unsigned long long fuzzy_size = 0;
void* fuzzy_basis =
fuzzy_basis_find_and_load(config, state->check_path, state->check_size, &fuzzy_size);
if (fuzzy_basis != NULL) {
bool fuzzy_failed = false;
File* fuzzy_file = receive_delta_file(state->fd, config, state->check_path, fuzzy_basis,
fuzzy_size, &fuzzy_failed);
fuzzy_basis = NULL; /* receive_delta_file consumes the buffer on every path */
if (fuzzy_file) {
*out_file = fuzzy_file;
return INCREMENTAL_FILE;
}
if (fuzzy_failed)
return INCREMENTAL_ERROR;
}
free(fuzzy_basis);
return INCREMENTAL_CONTINUE;
}
/* Final fallback: tell the sender to transmit the whole file and receive it. */
static File* incremental_check_receive_full(IncrementalCheckState* state) {
if (!send_status(state->fd, STATUS_NEXT))
return NULL;
if (state->old_fd >= 0) {
close(state->old_fd);
state->old_fd = -1;
}
return receive_full_file(state->fd, state->config, state->check_path);
}
/* Core implementation. `would_transfer` (may be NULL) is set true only on the
* server-contacting --dry-run path, when the file is not up to date and the
* receiver answered STATUS_DRY_RUN_TRANSFER; the caller then knows no File is
* returned and nothing was stored. */
File* receive_incremental_check_ex(int fd, const Config* config, bool* skipped,
bool* would_transfer) {
if (would_transfer)
*would_transfer = false;
if (!config || !skipped) {
send_status(fd, STATUS_ERROR);
return NULL;
}
*skipped = false;
IncrementalCheckState state;
incremental_check_state_init(&state, fd, config);
File* result = NULL;
bool try_delta = false;
IncrementalCheckOutcome outcome;
outcome = incremental_check_receive_request(&state);
if (outcome == INCREMENTAL_ERROR)
goto done;
outcome = incremental_check_open_destination(&state);
if (outcome == INCREMENTAL_ERROR)
goto done;
outcome = incremental_check_report_dest_info(&state);
if (outcome == INCREMENTAL_ERROR)
goto done;
outcome = incremental_check_quick_skip(&state, &try_delta);
if (outcome == INCREMENTAL_ERROR)
goto done;
if (outcome == INCREMENTAL_SKIP) {
*skipped = true;
goto done;
}
/* Dry-run resolves here (no mutation) or falls through to the normal path. */
outcome = incremental_check_dry_run_shortcut(&state, skipped, would_transfer);
if (outcome == INCREMENTAL_ERROR)
goto done;
if (outcome != INCREMENTAL_CONTINUE)
goto done;
outcome = incremental_check_try_basis(&state, &result);
if (outcome == INCREMENTAL_ERROR)
goto done;
if (outcome == INCREMENTAL_SKIP) {
*skipped = true;
goto done;
}
if (outcome == INCREMENTAL_FILE)
goto done;
outcome = incremental_check_try_append_resume(&state, &result);
if (outcome == INCREMENTAL_ERROR)
goto done;
if (outcome == INCREMENTAL_FILE)
goto done;
outcome = incremental_check_try_delta(&state, try_delta, &result);
if (outcome == INCREMENTAL_ERROR)
goto done;
if (outcome == INCREMENTAL_FILE)
goto done;
outcome = incremental_check_try_fuzzy(&state, &result);
if (outcome == INCREMENTAL_ERROR)
goto done;
if (outcome == INCREMENTAL_FILE)
goto done;
result = incremental_check_receive_full(&state);
done:
incremental_check_state_cleanup(&state);
return result;
}
File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
return receive_incremental_check_ex(fd, config, skipped, NULL);
}
File* file_receive(const Config* config, int file_descriptor) {
char* path = receive_wire_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received file path: %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
free(path);
return NULL;
}
File* file = file_create(path);
free(path);
if (file == NULL)
return NULL;
if (config->use_metadata) {
int meta_ok = 1;
file->metadata = metadata_receive(file_descriptor, &meta_ok);
if (!meta_ok) {
file_destroy(file);
return NULL;
}
}
if (!receive_file_xattrs(file, file_descriptor, config)) {
file_destroy(file);
return NULL;
}
Data* file_data = receive_data_limited(file_descriptor, MAX_RECEIVE_WHOLE_FILE_SIZE);
if (file_data == NULL) {
file_destroy(file);
return NULL;
}
if (config->use_compression &&
!compression_should_skip_with_suffixes(file->path, config->skip_compress_suffixes,
config->skip_compress_set ? config->skip_compress_count
: -1)) {
Data* file_data_uncompressed = data_decompress_limited(file_data, MAX_RECEIVE_WHOLE_FILE_SIZE);
ProtocolSession* owner = file_data->owner;
data_destroy(file_data);
if (file_data_uncompressed == NULL) {
file_destroy(file);
return NULL;
}
if (!data_charge_session(file_data_uncompressed, owner, file_data_uncompressed->size)) {
data_destroy(file_data_uncompressed);
file_destroy(file);
return NULL;
}
if (file_data_uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(file_data_uncompressed);
file_destroy(file);
return NULL;
}
file_data = file_data_uncompressed;
}
data_destroy(file->data);
file->data = file_data;
return file;
}
/* ---- P7 Wave D: deferred directory metadata ---- */
bool dir_metadata_should_capture(const Config* config) {
/* Directory metadata is captured when a directory attribute is actually
* requested: -p/--perms (directory modes), -t/--times (directory mtimes,
* unless -O/--omit-dir-times suppresses them), -o/-g (directory ownership),
* or -X/-A (directory xattrs/ACLs). --atimes/-U alone does not pull
* directory metadata (matching the original dir-time bundle). */
return config && config->use_metadata &&
(config->preserve_perms || (config->preserve_times && !config->omit_dir_times) ||
config->preserve_owner || config->preserve_group || config->preserve_xattrs ||
config->preserve_acls);
}
void dir_time_list_init(DirTimeList* list) {
if (!list)
return;
list->paths = NULL;
list->entries = NULL;
list->xattrs = NULL;
list->count = 0;
list->capacity = 0;
list->bytes = 0;
}
void dir_time_list_free(DirTimeList* list) {
if (!list)
return;
for (size_t i = 0; i < list->count; i++) {
free(list->paths[i]);
xattr_list_free(list->xattrs ? list->xattrs[i] : NULL);
}
free(list->paths);
free(list->entries);
free(list->xattrs);
list->paths = NULL;
list->entries = NULL;
list->xattrs = NULL;
list->count = 0;
list->capacity = 0;
list->bytes = 0;
}
bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetadata* metadata,
const FileXattrList* xattrs) {
if (!list || !wire_path || !metadata)
return true; /* nothing to remember; never a hard error */
/* Cumulative, not per-frame: the sender may stream a tree across unbounded
STATUS_DIR_TIMES frames, so bound the TOTAL retained here. Reject before
touching the list, leaving it exactly as it was (the caller fails the
transfer, which becomes a clean protocol error). */
size_t path_len = strlen(wire_path);
/* Charge the whole per-entry cost (path copy + pointer slot + metadata
struct + captured xattrs), not just the path, so the array growth is
bounded by the same cumulative budget. */
size_t xattr_cost = 0;
if (xattrs) {
for (int i = 0; i < xattrs->count; i++)
xattr_cost += strlen(xattrs->items[i].name) + xattrs->items[i].value_len + sizeof(FileXattr);
}
size_t entry_cost = path_len + sizeof(FileMetadata) + 2 * sizeof(char*) + xattr_cost;
if (list->count >= MAX_DIR_TIME_ENTRIES || entry_cost > MAX_DIR_TIME_BYTES - list->bytes)
return false;
if (list->count == list->capacity) {
size_t new_capacity = list->capacity == 0 ? 16 : list->capacity * 2;
if (new_capacity < list->capacity)
return false;
/* Assign each grown array as soon as its realloc succeeds: the old block is
already freed by then, so discarding the pointer would dangle. capacity
is advanced only after ALL reallocs succeed, so a partial failure leaves
capacity no larger than the smallest allocation -- never a mismatched
list the next add could write past. */
char** grown_paths = realloc(list->paths, new_capacity * sizeof(char*));
if (!grown_paths)
return false;
list->paths = grown_paths;
FileMetadata* grown_entries = realloc(list->entries, new_capacity * sizeof(FileMetadata));
if (!grown_entries)
return false;
list->entries = grown_entries;
FileXattrList** grown_xattrs = realloc(list->xattrs, new_capacity * sizeof(FileXattrList*));
if (!grown_xattrs)
return false;
list->xattrs = grown_xattrs;
list->capacity = new_capacity;
}
char* copy = str_dup(wire_path);
if (!copy)
return false;
FileXattrList* xattr_copy = xattr_list_clone(xattrs);
if (xattrs && !xattr_copy) {
free(copy);
return false;
}
list->paths[list->count] = copy;
list->entries[list->count] = *metadata;
list->xattrs[list->count] = xattr_copy;
list->count++;
list->bytes += entry_cost;
return true;
}
void dir_metadata_list_apply(const DirTimeList* list, const char* root_directory,
const Config* config) {
if (!list || !root_directory || !config)
return;
bool apply_times = config->preserve_times && !config->omit_dir_times;
bool apply_mode = config->preserve_perms;
bool apply_xattrs = config->use_xattrs;
/* Ownership is applied through the active identity snapshot (which no-ops
* unless an ownership request is active), and xattrs only when -X/-A was
* negotiated. Times/mode keep their own per-attribute gates. */
bool have_any = apply_times || apply_mode || apply_xattrs || identity_active_enabled();
if (!have_any)
return;
for (size_t i = 0; i < list->count; i++) {
char* dir_path = path_cat(root_directory, list->paths[i]);
if (!dir_path)
continue;
char* leaf = NULL;
/* The parent walk is fd-relative and O_NOFOLLOW, so a symlink planted in a
parent component can never redirect the utimensat/chmod outside the
root. */
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
if (parent_fd < 0) {
free(dir_path);
continue;
}
/* A dir-time entry only records metadata: the directory is (deliberately)
not created from it, so an empty source directory (or one pruned by
-m/--prune-empty-dirs) may well not exist here. Skip absent paths
QUIETLY rather than warning for every one, and apply the metadata only to
a real directory that does exist. AT_SYMLINK_NOFOLLOW keeps a same-named
symlink from being followed; a pre-existing regular file/symlink is not a
directory, so it is left completely untouched. */
struct stat st;
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0 || !S_ISDIR(st.st_mode)) {
close(parent_fd);
free(leaf);
free(dir_path);
continue;
}
/* One O_DIRECTORY|O_NOFOLLOW fd drives ownership/mode/xattr application so
none of them can follow a same-named symlink planted after the fstatat. */
int dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
/* Ownership first: a chown clears setuid/setgid, so it must precede mode. */
if (dir_fd >= 0)
identity_apply_ownership(dir_fd, (int32_t)list->entries[i].uid,
(int32_t)list->entries[i].gid);
if (apply_times) {
struct timespec times[2] = {
{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = list->entries[i].mtime_sec, .tv_nsec = list->entries[i].mtime_nsec}};
if (config->preserve_atimes && list->entries[i].atime_valid) {
times[0].tv_sec = list->entries[i].atime_sec;
times[0].tv_nsec = list->entries[i].atime_nsec;
}
if (utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW) != 0) {
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Failed to set directory timestamps on %s: %s",
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
free(escaped_path);
}
}
if (apply_mode) {
mode_t dir_mode = list->entries[i].mode;
bool mode_ready = true;
if (config->chmod_spec && *config->chmod_spec &&
!chmod_apply(dir_mode, config->chmod_spec, &dir_mode)) {
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Failed to apply --chmod to directory %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
mode_ready = false;
}
if (mode_ready) {
/* rsync -p copies the source directory mode exactly, including
* group/other write and the setgid/sticky bits. */
mode_t safe_mode = dir_mode & (mode_t)(S_ISUID | S_ISGID | S_ISVTX | 0777);
if (dir_fd < 0) {
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Failed to open directory %s to set its mode: %s",
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
free(escaped_path);
} else if (fchmod(dir_fd, safe_mode) != 0) {
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Failed to set directory mode on %s: %s",
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
free(escaped_path);
}
}
}
/* xattrs/ACLs last: a mode change can rewrite the ACL mask, so the ACL
xattrs must be (re)applied after fchmod. */
if (apply_xattrs && dir_fd >= 0 && list->xattrs)
xattr_apply_fd(dir_fd, list->xattrs[i]);
if (dir_fd >= 0)
close(dir_fd);
close(parent_fd);
free(leaf);
free(dir_path);
}
}
/* Receive an explicit directory entry (--dirs): a STATUS_MKDIR frame carries
the destination path and, when metadata is negotiated, the directory's
metadata frame. The same path validation as a regular file applies
(non-empty, relative-or-mirrored, no traversal), and the created File is
routed through the regular store_file sink so single-threaded and -m
receivers handle directories identically. The metadata is NOT applied here:
the sink accumulates it into a DirTimeList that is applied only after the
whole transfer (children would otherwise clobber the directory mtime). */
File* file_receive_directory(int file_descriptor, const Config* config) {
char* path = receive_wire_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received directory path: %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
free(path);
return NULL;
}
File* file = file_create(path);
free(path);
if (file == NULL)
return NULL;
file->is_dir = true;
if (config && config->use_metadata) {
int meta_ok = 1;
file->metadata = metadata_receive(file_descriptor, &meta_ok);
if (!meta_ok) {
file_destroy(file);
return NULL;
}
}
/* Directory xattrs/ACLs (-X/-A) ride after the metadata when negotiated. */
if (config && !receive_file_xattrs(file, file_descriptor, config)) {
file_destroy(file);
return NULL;
}
return file;
}
/* Receive one directory-time entry from a STATUS_DIR_TIMES frame: the
* destination-relative wire path and (when metadata is negotiated) the
* directory's metadata frame. The created File is an is_dir, dir_time_only
* entry routed through the regular store_file sink: the sink records its
* metadata into the deferred DirTimeList but never creates the directory (the
* scanner captures every traversed directory, including empty ones). Unlike a
* STATUS_MKDIR entry, this one must not create anything. */
File* file_receive_dir_time(int file_descriptor, const Config* config) {
char* path = receive_wire_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received directory-time path: %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
free(path);
return NULL;
}
File* file = file_create(path);
free(path);
if (!file)
return NULL;
file->is_dir = true;
file->dir_time_only = true;
if (config && config->use_metadata) {
int meta_ok = 1;
file->metadata = metadata_receive(file_descriptor, &meta_ok);
if (!meta_ok) {
file_destroy(file);
return NULL;
}
}
/* Directory xattrs/ACLs (-X/-A) ride after the metadata, mirroring the
sender's send_dir_times(). */
if (config && !receive_file_xattrs(file, file_descriptor, config)) {
file_destroy(file);
return NULL;
}
return file;
}
/* Receive a --hard-links/-H sibling frame (the leading STATUS_HARDLINK code has
already been consumed): the destination path, the run-local link-group id,
and the first (data-carrying) member's destination-relative wire path. The
created File carries no payload; it is installed beneath the receive root as
a hard link to (or, on link failure, a byte-identical copy of) the first
member. All paths are validated like every other received path (non-empty,
relative, no traversal). */
File* file_receive_hardlink(int file_descriptor) {
char* path = receive_wire_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received hard-link path: %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
free(path);
send_error_detail(file_descriptor, "invalid hard-link path");
return NULL;
}
int gid;
if (!receive_int(file_descriptor, &gid) || gid <= 0) {
free(path);
return NULL;
}
char* target = receive_wire_str(file_descriptor);
if (!target) {
free(path);
return NULL;
}
if (target[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(target))) {
char* escaped = output_escape(target, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid hard-link target path: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
free(target);
free(path);
send_error_detail(file_descriptor, "invalid hard-link target path");
return NULL;
}
File* file = file_create(path);
free(path);
if (file == NULL) {
free(target);
return NULL;
}
file->link_group = gid;
file->link_first = false;
file->hardlink_target = target;
return file;
}
/* Receive a symlink entry (the leading STATUS_SYMLINK code has already been
consumed): the destination path and the (sender-munged, if --munge-links)
symlink target string, then metadata when negotiated. The created File is
routed through the regular store_file sink, which creates the link beneath
the receive root (unmungeing the target first). */
File* file_receive_symlink(int file_descriptor, const Config* config) {
char* path = receive_wire_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received symlink path: %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
free(path);
send_error_detail(file_descriptor, "invalid symlink path");
return NULL;
}
char* target = receive_wire_str(file_descriptor);
if (!target) {
free(path);
return NULL;
}
if (target[0] == '\0') {
char* escaped = output_escape(target, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received symlink target: %s",
escaped ? escaped : "<allocation failed>");
free(escaped);
free(target);
free(path);
send_error_detail(file_descriptor, "invalid symlink target");
return NULL;
}
File* file = file_create(path);
free(path);
if (!file) {
free(target);
return NULL;
}
if (config && config->use_metadata) {
int meta_ok = 1;
file->metadata = metadata_receive(file_descriptor, &meta_ok);
if (!meta_ok) {
file_destroy(file);
free(target);
return NULL;
}
}
file->is_symlink = true;
file->symlink_target = target;
return file;
}
/* Receive a device/special node frame (--devices/--specials): the leading
* STATUS_SPECIAL code has already been consumed. Payload: the destination path,
* the metadata frame (whose mode's S_IFMT bits carry the node kind), and two
* int32 rdev major/minor fields. The created File carries no payload and is
* recreated by file_save_to_disk_full (mknod/mkfifo, privilege-gated and
* confined). rdev is validated here (non-negative, range-checked) so a bogus
* value cannot drive a dangerous node on the receiver. */
File* file_receive_special(int file_descriptor) {
char* path = receive_wire_str(file_descriptor);
if (path == NULL)
return NULL;
if (path[0] == '\0' || (!file_get_trust_sender() && has_path_traversal(path))) {
char* escaped_path = output_escape(path, log_get_8_bit_output());
log_message(LOG_LEVEL_ERROR, "Invalid received special path: %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
free(path);
send_error_detail(file_descriptor, "invalid special path");
return NULL;
}
int meta_ok = 1;
FileMetadata* metadata = metadata_receive(file_descriptor, &meta_ok);
if (!meta_ok) {
free(path);
send_status(file_descriptor, STATUS_ERROR);
return NULL;
}
int32_t major = 0;
int32_t minor = 0;
if (!receive_n_data(file_descriptor, &major, sizeof(major)) ||
!receive_n_data(file_descriptor, &minor, sizeof(minor))) {
free(path);
file_metadata_destroy(metadata);
send_status(file_descriptor, STATUS_ERROR);
return NULL;
}
/* A node kind must be present; without metadata mode there is no S_IFMT to
recreate from. */
if (!metadata) {
log_message(LOG_LEVEL_ERROR, "Special node sent without metadata (mode)");
free(path);
send_error_detail(file_descriptor, "special node sent without metadata");
return NULL;
}
if (!file_special_rdev_valid(major, minor, metadata->mode)) {
log_message(LOG_LEVEL_ERROR, "Invalid special rdev received (%d:%d)", (int)major, (int)minor);
free(path);
file_metadata_destroy(metadata);
send_error_detail(file_descriptor, "invalid special device rdev");
return NULL;
}
File* file = file_create(path);
free(path);
if (file == NULL) {
file_metadata_destroy(metadata);
return NULL;
}
file->metadata = metadata;
file->is_special = true;
file->rdev_major = major;
file->rdev_minor = minor;
return file;
}
/* Read a delete-manifest frame (the STATUS_MANIFEST leading code has already
been consumed): a keep-set entry count followed by that many
destination-relative paths, then a protected-prefix count followed by that
many destination-relative prefixes, then a missing-args count followed by that
many destination-relative delete paths, then (protocol 2.23.0) a
synchronized-directory count followed by that many destination-relative
directory paths (the receive root is the "." sentinel). The frame is
self-delimiting (the counts are authoritative), so the caller decides what to
do next and continues reading the following STATUS_* frame. Every section is
validated identically: an entry must be non-empty, relative and traversal-free
and the aggregate length across ALL sections is capped by MAX_MANIFEST_BYTES
(so the missing-args deletion requests are confined like the rest of the
manifest). Returns an owned DeleteManifest, or NULL after sending STATUS_ERROR
when the frame is malformed (bad count, empty/absolute path, path traversal,
or an aggregate size beyond MAX_MANIFEST_BYTES). */
static bool receive_manifest_section(int fd, ArrayList* list, size_t* manifest_bytes,
size_t* manifest_entries) {
int count;
if (!receive_int(fd, &count)) {
send_status(fd, STATUS_ERROR);
return false;
}
if (count < 0 || count > MAX_MANIFEST_ENTRIES ||
(size_t)count > MAX_MANIFEST_ENTRIES - *manifest_entries) {
send_status(fd, STATUS_ERROR);
return false;
}
for (int i = 0; i < count; i++) {
char* s = receive_wire_str(fd);
size_t entry_size = s ? strlen(s) + MANIFEST_ENTRY_OVERHEAD : 0;
if (!s || s[0] == '\0' || s[0] == '/' || has_path_traversal(s) ||
entry_size > MAX_MANIFEST_BYTES - *manifest_bytes ||
(*manifest_bytes += entry_size) > MAX_MANIFEST_BYTES || !array_list_add(list, s)) {
free(s);
send_status(fd, STATUS_ERROR);
return false;
}
}
*manifest_entries += (size_t)count;
return true;
}
DeleteManifest* receive_manifest_entries(int fd) {
DeleteManifest* manifest = calloc(1, sizeof(DeleteManifest));
if (!manifest) {
send_status(fd, STATUS_ERROR);
return NULL;
}
manifest->keeps = array_list_create(free);
manifest->protected = array_list_create(free);
manifest->missing = array_list_create(free);
manifest->dirs = array_list_create(free);
if (!manifest->keeps || !manifest->protected || !manifest->missing || !manifest->dirs) {
delete_manifest_free(manifest);
send_status(fd, STATUS_ERROR);
return NULL;
}
size_t manifest_bytes = 0;
size_t manifest_entries = 0;
if (!receive_manifest_section(fd, manifest->keeps, &manifest_bytes, &manifest_entries) ||
!receive_manifest_section(fd, manifest->protected, &manifest_bytes, &manifest_entries) ||
!receive_manifest_section(fd, manifest->missing, &manifest_bytes, &manifest_entries) ||
!receive_manifest_section(fd, manifest->dirs, &manifest_bytes, &manifest_entries)) {
delete_manifest_free(manifest);
return NULL;
}
return manifest;
}
void delete_manifest_free(DeleteManifest* manifest) {
if (!manifest)
return;
array_list_delete(manifest->keeps);
array_list_delete(manifest->protected);
array_list_delete(manifest->missing);
array_list_delete(manifest->dirs);
free(manifest);
}
/* Shared --max-delete budget for one receiver-side deletion commit. Both the
--delete-missing-args exact-path removals and the ordinary extras walk draw
from the same tally, matching rsync (whose --max-delete counts every deleted
file or directory). `max_delete` is SIZE_MAX for an unlimited budget. */
typedef struct {
size_t max_delete;
size_t deleted;
size_t skipped;
bool limit_hit;
} DeleteBudgetState;
/* Remove every destination entry under the receive root that is not in the
keep-set, bounded by the shared budget (a smaller client --max-delete=NUM
replaces the server hard bound; rsync deletes up to the bound and skips the
rest). With --delay-updates the not-yet-published staging directory is a
direct child of the receive root and must not be treated as a set of extras;
the manifest's protected prefixes (paths excluded on the source), the
size-pruned prefixes (--max-size/--min-size, always protected) and the
alternate basis directories are never destination content and are skipped at
any depth. Returns true unless a traversal/unlink error aborted the walk;
the budget's limit_hit/skipped fields report a cap-stopped run. */
static bool delete_extras_budgeted(const Config* config, DeleteManifest* manifest,
DeleteBudgetState* budget) {
if (!config || !manifest || !manifest->keeps)
return false;
fprintf(stderr, "Deleting files not in manifest...\n");
/* Protected entries:
- the --delay-updates staging name, protected only as a DIRECT child of the
receive root (a nested destination directory that happens to be named
.fastsync-stage is ordinary content);
- alternate basis directories (--compare-dest / --copy-dest / --link-dest)
at any depth: they are extra comparison snapshots the user pointed at,
not destination content, and deleting them would destroy the very files a
--link-dest run just linked into place;
- the sender-side protected prefixes (source paths excluded by filters and
paths pruned by --max-size/--min-size), at any depth, so their destination
mirror survives --delete unless --delete-excluded opts back into removing
the filter-excluded ones (size-pruned entries are always protected). */
int skip_count = (config->delay_updates ? 1 : 0) + config->basis_count +
(manifest->protected ? manifest->protected->size : 0);
DeleteSkipEntry* skips = NULL;
if (skip_count > 0) {
skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry));
if (!skips)
return false;
int idx = 0;
if (config->delay_updates) {
skips[idx].prefix = DELAY_UPDATES_STAGING_DIR;
skips[idx].top_level_only = true;
idx++;
}
for (int i = 0; i < config->basis_count; i++) {
skips[idx].prefix = config->basis_dirs[i].path;
skips[idx].top_level_only = false;
idx++;
}
for (int i = 0; i < manifest->protected->size; i++) {
skips[idx].prefix = (const char*)manifest->protected->items[i];
skips[idx].top_level_only = false;
idx++;
}
}
/* Clamp rather than subtract: an accounting bug where deleted already exceeds
max_delete must never underflow into an effectively unlimited budget. */
size_t remaining;
if (budget->max_delete == SIZE_MAX)
remaining = SIZE_MAX;
else if (budget->deleted >= budget->max_delete)
remaining = 0;
else
remaining = budget->max_delete - budget->deleted;
size_t deleted = 0;
size_t skipped = 0;
DeleteWalkResult result =
delete_extras_limited(config->receive_root_directory, manifest->keeps, manifest->dirs,
remaining, skips, skip_count, &deleted, &skipped);
free(skips);
budget->deleted += deleted;
budget->skipped += skipped;
if (result == DELETE_WALK_LIMIT_REACHED) {
budget->limit_hit = true;
return true;
}
if (result != DELETE_WALK_OK) {
log_message(LOG_LEVEL_ERROR, "deletion failed while removing extraneous files");
return false;
}
return true;
}
/* --delete-missing-args exact-path deletions: each destination mirror in
manifest->missing is an explicit user request, so it is removed even when the
ordinary extras walk (with its protected prefixes) would leave it alone. The
--delay-updates staging directory and basis snapshots are receiver artifacts
and stay protected exactly as in the extras walker. A regular file or
symlink is unlinked, an empty directory removed, and a NON-empty directory is
removed recursively only when --delete or --force is in effect (rsync parity:
the man page says a non-empty directory mirror is only deleted with --force
or --delete); otherwise it is left with a warning and the run continues. A
mirror that does not exist is a no-op. Each removal draws from the shared
--max-delete budget: once it is exhausted the remaining requests are skipped
and counted. Returns false only on a genuine error (a confinement failure on
a validated path or an I/O error), which fails the run. */
static bool delete_missing_args_budgeted(const Config* config, DeleteManifest* manifest,
DeleteBudgetState* budget) {
if (!config || !manifest)
return false;
if (!manifest->missing || manifest->missing->size == 0)
return true;
fprintf(stderr, "Deleting destination mirrors of missing source arguments...\n");
int skip_count = (config->delay_updates ? 1 : 0) + config->basis_count;
DeleteSkipEntry* skips = NULL;
if (skip_count > 0) {
skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry));
if (!skips)
return false;
int idx = 0;
if (config->delay_updates) {
skips[idx].prefix = DELAY_UPDATES_STAGING_DIR;
skips[idx].top_level_only = true;
idx++;
}
for (int i = 0; i < config->basis_count; i++) {
skips[idx].prefix = config->basis_dirs[i].path;
skips[idx].top_level_only = false;
idx++;
}
}
bool ok = true;
for (int i = 0; i < manifest->missing->size; i++) {
const char* rel = (const char*)manifest->missing->items[i];
if (!rel || *rel == '\0' || *rel == '/' || has_path_traversal(rel)) {
/* Defensive only: receive_manifest_entries already validated every
section identically, so a controlled peer never reaches this branch. */
log_message(LOG_LEVEL_ERROR, "invalid missing-args delete path");
ok = false;
continue;
}
bool at_root = strchr(rel, '/') == NULL;
if (path_under_skip_prefix(rel, at_root, skips, skip_count)) {
char* escaped = output_escape(rel, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING,
"missing-args path '%s' is protected (staging directory or basis snapshot); "
"not deleting",
escaped ? escaped : "<allocation failed>");
free(escaped);
continue;
}
char* full = path_cat(config->receive_root_directory, rel);
if (!full) {
ok = false;
continue;
}
char* leaf = NULL;
int parent_fd = file_open_secure_parent(full, &leaf, false);
if (parent_fd < 0) {
/* The mirror's parent directory may itself not exist on the destination
(a deeper missing entry whose leading directories were never created).
That is a no-op -- there is nothing to delete -- matching
file_remove_tree_secure's absent-path handling; only a genuine I/O
error (EACCES, a symlink loop, ...) fails the run. */
bool absent = errno == ENOENT || errno == ENOTDIR;
free(full);
free(leaf);
if (!absent)
ok = false;
continue;
}
struct stat st;
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0) {
/* Already absent: nothing to delete (a no-op, not a deletion). */
if (errno != ENOENT)
ok = false;
close(parent_fd);
free(leaf);
free(full);
continue;
}
/* An entry that exists is one deletion: skip it (and count it) when the
shared --max-delete budget is already exhausted. */
if (budget->deleted >= budget->max_delete) {
budget->limit_hit = true;
budget->skipped++;
close(parent_fd);
free(leaf);
free(full);
continue;
}
bool removed = false;
if (S_ISDIR(st.st_mode)) {
if (unlinkat(parent_fd, leaf, AT_REMOVEDIR) == 0) {
removed = true;
} else if (errno == ENOTEMPTY || errno == EEXIST) {
close(parent_fd);
parent_fd = -1;
free(leaf);
leaf = NULL;
if (config->use_delete || config->force_delete) {
/* Remove the contents entry-by-entry through the budgeted extras
walker so every deleted file/dir counts toward --max-delete (rsync
parity); the now-empty directory itself costs one more. A run that
hits the cap leaves the remaining entries in place. */
ArrayList* no_keeps = array_list_create(free);
/* Never let an accounting slip (deleted > max_delete) underflow the
remaining budget into SIZE_MAX, which would grant unlimited
deletions. */
size_t remaining =
budget->deleted >= budget->max_delete ? 0 : budget->max_delete - budget->deleted;
size_t contents_deleted = 0;
size_t contents_skipped = 0;
DeleteWalkResult walk =
no_keeps ? delete_extras_limited(full, no_keeps, NULL, remaining, NULL, 0,
&contents_deleted, &contents_skipped)
: DELETE_WALK_ERROR;
if (no_keeps)
array_list_delete(no_keeps);
budget->deleted += contents_deleted;
budget->skipped += contents_skipped;
if (walk == DELETE_WALK_LIMIT_REACHED) {
budget->limit_hit = true;
} else if (walk != DELETE_WALK_OK) {
ok = false;
} else if (budget->deleted >= budget->max_delete) {
budget->limit_hit = true;
budget->skipped++;
} else if (file_remove_tree_secure(full)) {
/* The shared `if (removed)` tail charges this directory exactly
once; counting it here too would consume two budget units. */
removed = true;
} else {
ok = false;
}
} else {
char* escaped = output_escape(rel, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING,
"missing-args destination '%s' is a non-empty directory; use --force or "
"--delete to remove it",
escaped ? escaped : "<allocation failed>");
free(escaped);
}
} else if (errno != ENOENT) {
ok = false;
}
} else {
if (unlinkat(parent_fd, leaf, 0) == 0) {
removed = true;
} else if (errno != ENOENT) {
ok = false;
}
}
if (removed) {
budget->deleted++;
char* escaped = output_escape(rel, log_get_8_bit_output());
fprintf(stderr, " Deleted: %s\n", escaped ? escaped : "<allocation failed>");
free(escaped);
}
if (parent_fd >= 0)
close(parent_fd);
free(leaf);
free(full);
if (!ok)
break;
}
free(skips);
return ok;
}
/* Public wrappers used outside the commit path (and by unit tests): no
--max-delete budget. */
bool manifest_delete_extras(const Config* config, DeleteManifest* manifest) {
DeleteBudgetState budget = {
.max_delete = SIZE_MAX, .deleted = 0, .skipped = 0, .limit_hit = false};
return delete_extras_budgeted(config, manifest, &budget);
}
bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest) {
DeleteBudgetState budget = {
.max_delete = SIZE_MAX, .deleted = 0, .skipped = 0, .limit_hit = false};
return delete_missing_args_budgeted(config, manifest, &budget);
}
/* Commit every deletion family the manifest carries. The --delete-missing-args
exact-path deletions run FIRST: they are explicit user requests and must not
be blocked by the extras walker's filter-exclusion protection (a protected
leftover inside a missing-argument directory must not make that user-requested
removal fail). The ordinary extras walk then runs when --delete is active.
Both draw from one --max-delete budget; the result reports a cap-stopped
(partial) commit distinctly so the client can exit 25 like rsync. */
DeleteCommitResult manifest_delete_all(const Config* config, DeleteManifest* manifest) {
if (!config || !manifest)
return DELETE_COMMIT_ERROR;
/* Central no-mutation guard: a dry-run never deletes. No manifest is sent on
the dry-run path, but a hostile/buggy peer could; treat it as a no-op so
the receiver can never remove anything. */
if (config->dry_run)
return DELETE_COMMIT_OK;
/* A client --max-delete=NUM smaller than the server's hard bound replaces it
for this run; both still bound the commit. */
bool user_limited =
config->max_delete >= 0 && (size_t)config->max_delete < MAX_SERVER_DELETE_COUNT;
DeleteBudgetState budget = {.max_delete = user_limited ? (size_t)config->max_delete
: MAX_SERVER_DELETE_COUNT,
.deleted = 0,
.skipped = 0,
.limit_hit = false};
if (config->delete_missing_args && !delete_missing_args_budgeted(config, manifest, &budget))
return DELETE_COMMIT_ERROR;
if (config->use_delete && !delete_extras_budgeted(config, manifest, &budget))
return DELETE_COMMIT_ERROR;
if (budget.limit_hit) {
if (user_limited) {
log_message(LOG_LEVEL_ERROR, "Deletions stopped due to --max-delete limit (%zu skipped)",
budget.skipped);
} else {
log_message(LOG_LEVEL_ERROR,
"Deletions stopped due to the server deletion limit of %u (%zu skipped)",
(unsigned)MAX_SERVER_DELETE_COUNT, budget.skipped);
}
return DELETE_COMMIT_LIMIT_REACHED;
}
return DELETE_COMMIT_OK;
}