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.
This commit is contained in:
2026-09-07 14:34:19 +02:00
parent 6701c103cb
commit c9bd76e633
8 changed files with 240 additions and 31 deletions
+8 -5
View File
@@ -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 so the sender only starts streaming once the deletion committed (or
failed). This is the rsync delete-before/delete-during window: a failed). This is the rsync delete-before/delete-during window: a
later transfer failure does not restore these deletions. */ 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); delete_manifest_free(manifest);
if (!deletion_ok) { if (!deletion_ok) {
send_status(file_descriptor, STATUS_ERROR); 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)) if (!send_status(file_descriptor, STATUS_OK))
goto fail; goto fail;
} else if (config->use_delete) { } else if (config->use_delete || config->delete_missing_args) {
/* Plain --delete / --delete-after / --delete-delay: hold the keep-set /* Plain --delete / --delete-after / --delete-delay and the
and commit the deletion only after STATUS_FINISHED. */ --delete-missing-args exact-path deletions: hold the manifest and
commit it only after STATUS_FINISHED. */
if (deferred_manifest) { if (deferred_manifest) {
log_message(LOG_LEVEL_ERROR, "Received a second delete manifest"); log_message(LOG_LEVEL_ERROR, "Received a second delete manifest");
delete_manifest_free(deferred_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; *pending_manifest = deferred_manifest;
deferred_manifest = NULL; deferred_manifest = NULL;
} else { } else {
bool deletion_ok = manifest_delete_extras(config, deferred_manifest); bool deletion_ok = manifest_delete_all(config, deferred_manifest);
delete_manifest_free(deferred_manifest); delete_manifest_free(deferred_manifest);
deferred_manifest = NULL; deferred_manifest = NULL;
if (!deletion_ok) { if (!deletion_ok) {
+6 -1
View File
@@ -180,6 +180,11 @@ void handler(int file_descriptor) {
return; return;
} }
config->use_delete = config->use_delete && allow_delete; 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) /* --mkpath: create the destination root (and its missing leading components)
before anything else; without it the root must pre-exist. A failure here before anything else; without it the root must pre-exist. A failure here
aborts the connection cleanly before any file data is exchanged. */ 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 known to have succeeded. Remove the extras before publishing a
--delay-updates run; the walker skips the staging directory. */ --delay-updates run; the walker skips the staging directory. */
if (context->deferred_manifest) { if (context->deferred_manifest) {
if (!manifest_delete_extras(config, context->deferred_manifest)) { if (!manifest_delete_all(config, context->deferred_manifest)) {
transfer_ok = false; transfer_ok = false;
} }
delete_manifest_free(context->deferred_manifest); delete_manifest_free(context->deferred_manifest);
+23 -10
View File
@@ -96,6 +96,8 @@ static void config_set_defaults(Config* config) {
config->max_delete = -1; config->max_delete = -1;
config->ignore_errors = false; config->ignore_errors = false;
config->force_delete = false; config->force_delete = false;
config->ignore_missing_args = false;
config->delete_missing_args = false;
config->filters = NULL; config->filters = NULL;
config->files_from = NULL; config->files_from = NULL;
config->files_from_set = 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->inplace) && valid_wire_bool(config->append) &&
valid_wire_bool(config->use_fsync) && valid_wire_bool(config->append_verify) && 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_excluded) && valid_wire_bool(config->force_delete) &&
valid_wire_bool(config->delete_after) && valid_wire_bool(config->delete_delay) && valid_wire_bool(config->delete_missing_args) && valid_wire_bool(config->delete_after) &&
valid_wire_bool(config->delete_during) && valid_wire_bool(config->relative) && valid_wire_bool(config->delete_delay) && valid_wire_bool(config->delete_during) &&
valid_wire_bool(config->prune_empty_dirs) && valid_wire_bool(config->delay_updates) && valid_wire_bool(config->relative) && valid_wire_bool(config->prune_empty_dirs) &&
valid_wire_bool(config->mkpath) && !(config->delay_updates && config->inplace) && 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)) && !(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->partial) && valid_wire_bool(config->delete_before) &&
valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) && 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->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->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_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->delete_missing_args) && send_int(fd, c->delete_after) &&
send_int(fd, c->relative) && send_int(fd, c->prune_empty_dirs) && send_n_data(fd, &c->max_delete, sizeof(c->max_delete)) && send_int(fd, c->relative) &&
send_int(fd, c->mkpath) && send_int(fd, c->delete_during) && send_int(fd, c->delete_delay); 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) { 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) { static bool receive_selection_options(int fd, Config* c) {
bool* flags[] = {&c->ignore_existing, &c->existing, &c->update, &c->inplace, bool* flags[] = {&c->ignore_existing,
&c->delay_updates, &c->append, &c->use_fsync, &c->append_verify, &c->existing,
&c->delete_excluded, &c->force_delete, &c->delete_after}; &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++) { for (size_t i = 0; i < sizeof(flags) / sizeof(flags[0]); i++) {
if (!receive_wire_bool(fd, flags[i])) if (!receive_wire_bool(fd, flags[i]))
return false; return false;
+14 -1
View File
@@ -145,6 +145,19 @@ typedef struct Config {
* directory by removing that (possibly non-empty, symlink-safe) directory * directory by removing that (possibly non-empty, symlink-safe) directory
* tree first, instead of failing the write. Crosses the wire. */ * tree first, instead of failing the write. Crosses the wire. */
bool force_delete; 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 // Issue #129: Advanced file selection. These fields are CLIENT-ONLY: they are
// never serialized to the wire (the receiver must not learn them). // never serialized to the wire (the receiver must not learn them).
@@ -231,7 +244,7 @@ typedef struct Config {
DelayUpdatesContext* delay_context; DelayUpdatesContext* delay_context;
} Config; } Config;
#define PROTOCOL_VERSION "2.9.0" #define PROTOCOL_VERSION "2.10.0"
#define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024) #define DEFAULT_CHUNK_SIZE (10 * 1024 * 1024)
/* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */ /* Upper bound on total basis-dir entries (rsync caps --link-dest at 20). */
#define MAX_BASIS_DIRS 64 #define MAX_BASIS_DIRS 64
+159 -8
View File
@@ -1380,12 +1380,16 @@ File* file_receive_directory(int file_descriptor) {
/* Read a delete-manifest frame (the STATUS_MANIFEST leading code has already /* Read a delete-manifest frame (the STATUS_MANIFEST leading code has already
been consumed): a keep-set entry count followed by that many been consumed): a keep-set entry count followed by that many
destination-relative paths, then a protected-prefix count followed by that destination-relative paths, then a protected-prefix count followed by that
many destination-relative prefixes. The frame is self-delimiting (the counts many destination-relative prefixes, then (protocol 2.10.0+) a missing-args
are authoritative), so the caller decides what to do next and continues count followed by that many destination-relative delete paths. The frame is
reading the following STATUS_* frame. Returns an owned DeleteManifest, or self-delimiting (the counts are authoritative), so the caller decides what to
NULL after sending STATUS_ERROR when the frame is malformed (bad count, do next and continues reading the following STATUS_* frame. Every section is
empty/absolute path, path traversal, or an aggregate size beyond validated identically: an entry must be non-empty, relative and traversal-free
MAX_MANIFEST_BYTES). */ 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) { static bool receive_manifest_section(int fd, ArrayList* list, size_t* manifest_bytes) {
int count; int count;
if (!receive_int(fd, &count)) { if (!receive_int(fd, &count)) {
@@ -1418,14 +1422,16 @@ DeleteManifest* receive_manifest_entries(int fd) {
} }
manifest->keeps = array_list_create(free); manifest->keeps = array_list_create(free);
manifest->protected = 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); delete_manifest_free(manifest);
send_status(fd, STATUS_ERROR); send_status(fd, STATUS_ERROR);
return NULL; return NULL;
} }
size_t manifest_bytes = 0; size_t manifest_bytes = 0;
if (!receive_manifest_section(fd, manifest->keeps, &manifest_bytes) || 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); delete_manifest_free(manifest);
return NULL; return NULL;
} }
@@ -1437,6 +1443,7 @@ void delete_manifest_free(DeleteManifest* manifest) {
return; return;
array_list_delete(manifest->keeps); array_list_delete(manifest->keeps);
array_list_delete(manifest->protected); array_list_delete(manifest->protected);
array_list_delete(manifest->missing);
free(manifest); free(manifest);
} }
@@ -1518,3 +1525,147 @@ bool manifest_delete_extras(const Config* config, DeleteManifest* manifest) {
} }
return true; 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 : "<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) {
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 : "<allocation failed>");
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 : "<allocation failed>");
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 : "<allocation failed>");
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 : "<allocation failed>");
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;
}
+23 -4
View File
@@ -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 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 source, protected at any depth). When --delete-excluded is given the sender
transmits an empty protected list so excluded destination mirrors are treated 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 { typedef struct DeleteManifest {
ArrayList* keeps; ArrayList* keeps;
ArrayList* protected; ArrayList* protected;
ArrayList* missing;
} DeleteManifest; } DeleteManifest;
void delete_manifest_free(DeleteManifest* manifest); void delete_manifest_free(DeleteManifest* manifest);
/* Read a delete-manifest frame: keep count + keeps, then protected count + /* Read a delete-manifest frame: keep count + keeps, then protected count +
protected prefixes (self-delimiting; the leading STATUS_MANIFEST code has been protected prefixes, then missing count + missing paths (self-delimiting; the
consumed). Returns an owned DeleteManifest, or NULL after signalling leading STATUS_MANIFEST code has been consumed). Returns an owned
STATUS_ERROR on a malformed frame. */ DeleteManifest, or NULL after signalling STATUS_ERROR on a malformed frame. */
DeleteManifest* receive_manifest_entries(int fd); DeleteManifest* receive_manifest_entries(int fd);
/* Remove destination entries under config->receive_root_directory that are not /* Remove destination entries under config->receive_root_directory that are not
in `manifest` (bounded, all-or-nothing walk; staging-dir, basis-dir and 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 caller decides WHEN to run it based on the negotiated delete timing. Returns
false (and the transfer fails) when the deletion cannot be committed. */ false (and the transfer fails) when the deletion cannot be committed. */
bool manifest_delete_extras(const Config* config, DeleteManifest* manifest); 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 /* Outcome of a single file_save_to_disk operation. The receiver needs to
distinguish "written" from "skipped" so --remove-source-files can be told distinguish "written" from "skipped" so --remove-source-files can be told
+2 -2
View File
@@ -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 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 set only protect DIRECT children of the receive root (at_root); nested
directories that share such a name stay ordinary destination content. */ directories that share such a name stay ordinary destination content. */
static bool path_under_skip_prefix(const char* child_rel, bool at_root, bool path_under_skip_prefix(const char* child_rel, bool at_root, const DeleteSkipEntry* skips,
const DeleteSkipEntry* skips, int skip_count) { int skip_count) {
for (int i = 0; i < skip_count; i++) { for (int i = 0; i < skip_count; i++) {
if (skips[i].top_level_only && !at_root) if (skips[i].top_level_only && !at_root)
continue; continue;
+5
View File
@@ -32,6 +32,11 @@ typedef struct {
const char* prefix; const char* prefix;
bool top_level_only; bool top_level_only;
} DeleteSkipEntry; } 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 /* Remove files/dirs under dest_root that are not listed in manifest without
ever descending into a protected prefix (see DeleteSkipEntry). When ever descending into a protected prefix (see DeleteSkipEntry). When
max_delete is not SIZE_MAX the run is all-or-nothing: extras are counted max_delete is not SIZE_MAX the run is all-or-nothing: extras are counted