From c9bd76e633a1df4e2be6c08a52da3370a88df077 Mon Sep 17 00:00:00 2001 From: TapTap Date: Mon, 7 Sep 2026 14:34:19 +0200 Subject: [PATCH] feat(shared): --delete-missing-args config/wire, manifest third section, receiver exact-path deletions PROTOCOL_VERSION 2.9.0 -> 2.10.0. The STATUS_MANIFEST frame gains a third section carrying destination-relative exact-delete paths (the missing --files-from entries' mirrors); the config frame gains a delete_missing_args bool (ignore_missing_args stays client-only). The receiver validates the third section like the keep-set and commits it with manifest_delete_all(): manifest_delete_missing_args runs first (explicit user requests, never blocked by protected-prefix exclusion protection; staging/basis protected; a non-empty directory mirror removed only under --force/--delete, rsync parity) and then the ordinary extras walk. Server --allow-delete gates it like --delete. --- src/server/receiver.c | 13 +-- src/server/server.c | 7 +- src/shared/config.c | 33 +++++--- src/shared/config.h | 15 +++- src/shared/file_receive.c | 167 ++++++++++++++++++++++++++++++++++++-- src/shared/file_receive.h | 27 +++++- src/shared/utils.c | 4 +- src/shared/utils.h | 5 ++ 8 files changed, 240 insertions(+), 31 deletions(-) diff --git a/src/server/receiver.c b/src/server/receiver.c index 0556292..ea6d29f 100644 --- a/src/server/receiver.c +++ b/src/server/receiver.c @@ -191,7 +191,9 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver so the sender only starts streaming once the deletion committed (or failed). This is the rsync delete-before/delete-during window: a later transfer failure does not restore these deletions. */ - bool deletion_ok = config->use_delete ? manifest_delete_extras(config, manifest) : true; + bool deletion_ok = (config->use_delete || config->delete_missing_args) + ? manifest_delete_all(config, manifest) + : true; delete_manifest_free(manifest); if (!deletion_ok) { send_status(file_descriptor, STATUS_ERROR); @@ -199,9 +201,10 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver } if (!send_status(file_descriptor, STATUS_OK)) goto fail; - } else if (config->use_delete) { - /* Plain --delete / --delete-after / --delete-delay: hold the keep-set - and commit the deletion only after STATUS_FINISHED. */ + } else if (config->use_delete || config->delete_missing_args) { + /* Plain --delete / --delete-after / --delete-delay and the + --delete-missing-args exact-path deletions: hold the manifest and + commit it only after STATUS_FINISHED. */ if (deferred_manifest) { log_message(LOG_LEVEL_ERROR, "Received a second delete manifest"); delete_manifest_free(deferred_manifest); @@ -246,7 +249,7 @@ int receiver_process_pending(Config* config, int file_descriptor, const Receiver *pending_manifest = deferred_manifest; deferred_manifest = NULL; } else { - bool deletion_ok = manifest_delete_extras(config, deferred_manifest); + bool deletion_ok = manifest_delete_all(config, deferred_manifest); delete_manifest_free(deferred_manifest); deferred_manifest = NULL; if (!deletion_ok) { diff --git a/src/server/server.c b/src/server/server.c index a8aa7e8..b042ffb 100644 --- a/src/server/server.c +++ b/src/server/server.c @@ -180,6 +180,11 @@ void handler(int file_descriptor) { return; } config->use_delete = config->use_delete && allow_delete; + /* --delete-missing-args deletes destination mirrors receiver-side, so it is + deletion and stays gated by the same --allow-delete server policy. When + the server policy is off the flag is inert (the missing entries are still + skipped via its implied --ignore-missing-args, but nothing is deleted). */ + config->delete_missing_args = config->delete_missing_args && allow_delete; /* --mkpath: create the destination root (and its missing leading components) before anything else; without it the root must pre-exist. A failure here aborts the connection cleanly before any file data is exchanged. */ @@ -262,7 +267,7 @@ void handler(int file_descriptor) { known to have succeeded. Remove the extras before publishing a --delay-updates run; the walker skips the staging directory. */ if (context->deferred_manifest) { - if (!manifest_delete_extras(config, context->deferred_manifest)) { + if (!manifest_delete_all(config, context->deferred_manifest)) { transfer_ok = false; } delete_manifest_free(context->deferred_manifest); diff --git a/src/shared/config.c b/src/shared/config.c index 49c25f0..09e733d 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -96,6 +96,8 @@ static void config_set_defaults(Config* config) { config->max_delete = -1; config->ignore_errors = false; config->force_delete = false; + config->ignore_missing_args = false; + config->delete_missing_args = false; config->filters = NULL; config->files_from = NULL; config->files_from_set = NULL; @@ -165,10 +167,11 @@ static bool validate_received_config(const Config* config) { valid_wire_bool(config->inplace) && valid_wire_bool(config->append) && valid_wire_bool(config->use_fsync) && valid_wire_bool(config->append_verify) && valid_wire_bool(config->delete_excluded) && valid_wire_bool(config->force_delete) && - valid_wire_bool(config->delete_after) && valid_wire_bool(config->delete_delay) && - valid_wire_bool(config->delete_during) && valid_wire_bool(config->relative) && - valid_wire_bool(config->prune_empty_dirs) && valid_wire_bool(config->delay_updates) && - valid_wire_bool(config->mkpath) && !(config->delay_updates && config->inplace) && + valid_wire_bool(config->delete_missing_args) && valid_wire_bool(config->delete_after) && + valid_wire_bool(config->delete_delay) && valid_wire_bool(config->delete_during) && + valid_wire_bool(config->relative) && valid_wire_bool(config->prune_empty_dirs) && + valid_wire_bool(config->delay_updates) && valid_wire_bool(config->mkpath) && + !(config->delay_updates && config->inplace) && !(config->delay_updates && delay_updates_staging_name_conflict(config->backup_dir)) && valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) && valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) && @@ -421,9 +424,10 @@ static bool send_selection_options(int fd, const Config* c) { send_int(fd, c->inplace) && send_int(fd, c->delay_updates) && send_int(fd, c->append) && send_int(fd, c->use_fsync) && send_int(fd, c->append_verify) && send_int(fd, c->delete_excluded) && send_int(fd, c->force_delete) && - send_int(fd, c->delete_after) && send_n_data(fd, &c->max_delete, sizeof(c->max_delete)) && - send_int(fd, c->relative) && send_int(fd, c->prune_empty_dirs) && - send_int(fd, c->mkpath) && send_int(fd, c->delete_during) && send_int(fd, c->delete_delay); + send_int(fd, c->delete_missing_args) && send_int(fd, c->delete_after) && + send_n_data(fd, &c->max_delete, sizeof(c->max_delete)) && send_int(fd, c->relative) && + send_int(fd, c->prune_empty_dirs) && send_int(fd, c->mkpath) && + send_int(fd, c->delete_during) && send_int(fd, c->delete_delay); } static bool send_skip_compress_options(int fd, const Config* c) { @@ -532,9 +536,18 @@ static bool receive_file_options(int fd, Config* c) { } static bool receive_selection_options(int fd, Config* c) { - bool* flags[] = {&c->ignore_existing, &c->existing, &c->update, &c->inplace, - &c->delay_updates, &c->append, &c->use_fsync, &c->append_verify, - &c->delete_excluded, &c->force_delete, &c->delete_after}; + bool* flags[] = {&c->ignore_existing, + &c->existing, + &c->update, + &c->inplace, + &c->delay_updates, + &c->append, + &c->use_fsync, + &c->append_verify, + &c->delete_excluded, + &c->force_delete, + &c->delete_missing_args, + &c->delete_after}; for (size_t i = 0; i < sizeof(flags) / sizeof(flags[0]); i++) { if (!receive_wire_bool(fd, flags[i])) return false; diff --git a/src/shared/config.h b/src/shared/config.h index 34b1552..41a7634 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -145,6 +145,19 @@ typedef struct Config { * directory by removing that (possibly non-empty, symlink-safe) directory * tree first, instead of failing the write. Crosses the wire. */ bool force_delete; + /* --ignore-missing-args (client-only, never serialized): a --files-from + * entry that does not exist under the source is silently skipped instead of + * failing the run. Sender-side only: nothing is sent for it and it never + * enters the keep-set. Implied by --delete-missing-args. */ + bool ignore_missing_args; + /* --delete-missing-args: implies --ignore-missing-args; additionally each + * missing entry's destination mirror (computed like a present entry's wire + * path) is deleted receiver-side. Crosses the wire and is gated by the + * server's --allow-delete policy like --delete. rsync-parity: independent + * of ordinary --delete processing (it does not imply --delete); a non-empty + * directory mirror is only removed with --force or --delete in effect, and + * the missing-args deletions are not counted toward --max-delete. */ + bool delete_missing_args; // Issue #129: Advanced file selection. These fields are CLIENT-ONLY: they are // never serialized to the wire (the receiver must not learn them). @@ -231,7 +244,7 @@ typedef struct Config { DelayUpdatesContext* delay_context; } Config; -#define PROTOCOL_VERSION "2.9.0" +#define PROTOCOL_VERSION "2.10.0" #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ #define MAX_BASIS_DIRS 64 diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index a937787..3406051 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -1380,12 +1380,16 @@ File* file_receive_directory(int file_descriptor) { /* 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. The frame is self-delimiting (the counts - are authoritative), so the caller decides what to do next and continues - reading the following STATUS_* frame. 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). */ + many destination-relative prefixes, then (protocol 2.10.0+) a missing-args + count followed by that many destination-relative delete paths. 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) { int count; if (!receive_int(fd, &count)) { @@ -1418,14 +1422,16 @@ DeleteManifest* receive_manifest_entries(int fd) { } manifest->keeps = array_list_create(free); manifest->protected = array_list_create(free); - if (!manifest->keeps || !manifest->protected) { + manifest->missing = array_list_create(free); + if (!manifest->keeps || !manifest->protected || !manifest->missing) { delete_manifest_free(manifest); send_status(fd, STATUS_ERROR); return NULL; } size_t manifest_bytes = 0; if (!receive_manifest_section(fd, manifest->keeps, &manifest_bytes) || - !receive_manifest_section(fd, manifest->protected, &manifest_bytes)) { + !receive_manifest_section(fd, manifest->protected, &manifest_bytes) || + !receive_manifest_section(fd, manifest->missing, &manifest_bytes)) { delete_manifest_free(manifest); return NULL; } @@ -1437,6 +1443,7 @@ void delete_manifest_free(DeleteManifest* manifest) { return; array_list_delete(manifest->keeps); array_list_delete(manifest->protected); + array_list_delete(manifest->missing); free(manifest); } @@ -1518,3 +1525,147 @@ bool manifest_delete_extras(const Config* config, DeleteManifest* manifest) { } 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. Returns false only on a genuine error + (a confinement failure on a validated path or an I/O error), which fails the + run. */ +bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest) { + 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 : ""); + 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) { + free(full); + free(leaf); + ok = false; + continue; + } + struct stat st; + if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0) { + if (errno != ENOENT) + ok = false; + close(parent_fd); + free(leaf); + free(full); + continue; + } + if (S_ISDIR(st.st_mode)) { + if (unlinkat(parent_fd, leaf, AT_REMOVEDIR) == 0 || errno == ENOENT) { + char* escaped = output_escape(rel, log_get_8_bit_output()); + fprintf(stderr, " Deleted: %s\n", escaped ? escaped : ""); + free(escaped); + } else if (errno == ENOTEMPTY || errno == EEXIST) { + close(parent_fd); + parent_fd = -1; + free(leaf); + leaf = NULL; + if (config->use_delete || config->force_delete) { + if (file_remove_tree_secure(full)) { + char* escaped = output_escape(rel, log_get_8_bit_output()); + fprintf(stderr, " Deleted: %s\n", escaped ? escaped : ""); + free(escaped); + } 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 : ""); + free(escaped); + } + } else { + ok = false; + } + } else { + if (unlinkat(parent_fd, leaf, 0) == 0 || errno == ENOENT) { + char* escaped = output_escape(rel, log_get_8_bit_output()); + fprintf(stderr, " Deleted: %s\n", escaped ? escaped : ""); + free(escaped); + } else { + ok = false; + } + } + if (parent_fd >= 0) + close(parent_fd); + free(leaf); + free(full); + if (!ok) + break; + } + free(skips); + return ok; +} + +/* 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. + Returns true when there was nothing to do or every requested deletion + committed. */ +bool manifest_delete_all(const Config* config, DeleteManifest* manifest) { + if (!config || !manifest) + return false; + if (config->delete_missing_args && !manifest_delete_missing_args(config, manifest)) + return false; + if (config->use_delete && !manifest_delete_extras(config, manifest)) + return false; + return true; +} diff --git a/src/shared/file_receive.h b/src/shared/file_receive.h index 3bc1cd0..54dae5c 100644 --- a/src/shared/file_receive.h +++ b/src/shared/file_receive.h @@ -16,17 +16,22 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped); prefixes the sender asks the receiver never to delete (paths excluded on the source, protected at any depth). When --delete-excluded is given the sender transmits an empty protected list so excluded destination mirrors are treated - as ordinary extras. */ + as ordinary extras. With --delete-missing-args a third section (`missing`) + carries the destination mirrors of explicitly-listed source entries that do + not exist: each is an exact deletion request, independent of the ordinary + extras walk (never blocked by the protected prefixes) and processed when the + manifest is committed. */ typedef struct DeleteManifest { ArrayList* keeps; ArrayList* protected; + ArrayList* missing; } DeleteManifest; void delete_manifest_free(DeleteManifest* manifest); /* Read a delete-manifest frame: keep count + keeps, then protected count + - protected prefixes (self-delimiting; the leading STATUS_MANIFEST code has been - consumed). Returns an owned DeleteManifest, or NULL after signalling - STATUS_ERROR on a malformed frame. */ + protected prefixes, then missing count + missing paths (self-delimiting; the + leading STATUS_MANIFEST code has been consumed). Returns an owned + DeleteManifest, or NULL after signalling STATUS_ERROR on a malformed frame. */ DeleteManifest* receive_manifest_entries(int fd); /* Remove destination entries under config->receive_root_directory that are not in `manifest` (bounded, all-or-nothing walk; staging-dir, basis-dir and @@ -34,6 +39,20 @@ DeleteManifest* receive_manifest_entries(int fd); caller decides WHEN to run it based on the negotiated delete timing. Returns false (and the transfer fails) when the deletion cannot be committed. */ bool manifest_delete_extras(const Config* config, DeleteManifest* manifest); +/* --delete-missing-args exact-path deletions: remove each destination mirror + in `manifest->missing` (never blocked by the protected prefixes, staging dir + and basis dirs excluded). A regular file/symlink is unlinked; an empty + directory is removed; a NON-empty directory is removed recursively only when + --delete or --force is in effect, otherwise it is left with a warning (rsync + parity). A missing path is a no-op. Returns false only on a genuine + confinement or I/O error (the run then fails); tolerated per-path cases are + reported and skipped. */ +bool manifest_delete_missing_args(const Config* config, DeleteManifest* manifest); +/* Run every deletion family the manifest carries: the --delete-missing-args + exact-path deletions first (user requests are not blocked by exclusion + protection), then the ordinary extras walk when --delete is active. Returns + true when nothing to do or everything committed. */ +bool manifest_delete_all(const Config* config, DeleteManifest* manifest); /* Outcome of a single file_save_to_disk operation. The receiver needs to distinguish "written" from "skipped" so --remove-source-files can be told diff --git a/src/shared/utils.c b/src/shared/utils.c index d2dbf89..308d88f 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -208,8 +208,8 @@ static bool is_dir_in_manifest(const char* rel_path, ArrayList* manifest) { therefore protects "a" and "a/b/c" but not "ab". Entries with top_level_only set only protect DIRECT children of the receive root (at_root); nested directories that share such a name stay ordinary destination content. */ -static bool path_under_skip_prefix(const char* child_rel, bool at_root, - const DeleteSkipEntry* skips, int skip_count) { +bool path_under_skip_prefix(const char* child_rel, bool at_root, const DeleteSkipEntry* skips, + int skip_count) { for (int i = 0; i < skip_count; i++) { if (skips[i].top_level_only && !at_root) continue; diff --git a/src/shared/utils.h b/src/shared/utils.h index 4206095..5603038 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -32,6 +32,11 @@ typedef struct { const char* prefix; bool top_level_only; } DeleteSkipEntry; +/* True when child_rel is, or lies below, one of the protected entries (a prefix + "a" protects "a" and "a/b/c" but not "ab"; top_level_only entries protect + only DIRECT children of the destination root, i.e. child_rel has no '/'). */ +bool path_under_skip_prefix(const char* child_rel, bool at_root, const DeleteSkipEntry* skips, + int skip_count); /* Remove files/dirs under dest_root that are not listed in manifest without ever descending into a protected prefix (see DeleteSkipEntry). When max_delete is not SIZE_MAX the run is all-or-nothing: extras are counted