From 934defa9659837ae935703618c46590d890a1120 Mon Sep 17 00:00:00 2001 From: TapTap Date: Tue, 22 Sep 2026 13:52:57 +0200 Subject: [PATCH] refactor(delete): consolidate delete engine into delete.c --- CMakeLists.txt | 1 + src/shared/delete.c | 656 +++++++++++++++++++++++++++++++++++++ src/shared/delete.h | 151 +++++++++ src/shared/delete_commit.c | 193 ++--------- src/shared/delete_commit.h | 6 +- src/shared/delete_plan.c | 49 +-- src/shared/delete_plan.h | 1 + src/shared/utils.c | 635 ----------------------------------- src/shared/utils.h | 94 ------ tests/test_shared_utils.c | 1 + 10 files changed, 845 insertions(+), 942 deletions(-) create mode 100644 src/shared/delete.c create mode 100644 src/shared/delete.h diff --git a/CMakeLists.txt b/CMakeLists.txt index eb01dbb..8f5dbf2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -99,6 +99,7 @@ set(SHARED_SRCS src/shared/daemon_limits.c src/shared/data.c src/shared/delay_updates.c + src/shared/delete.c src/shared/delete_commit.c src/shared/delete_plan.c src/shared/delta.c diff --git a/src/shared/delete.c b/src/shared/delete.c new file mode 100644 index 0000000..3e4fb62 --- /dev/null +++ b/src/shared/delete.c @@ -0,0 +1,656 @@ +#include "delete.h" + +#include "delay_updates.h" +#include "filter.h" +#include "log.h" +#include "utils.h" +#include +#include +#include +#include +#include +#include +#include +#include + +/* Build the keep-set index from the exact manifest entries only. A lookup of + `rel` succeeds iff `rel` is a kept entry, a kept directory, or an ancestor + directory of kept content (the old is_dir_in_manifest predicate); the sorted + view answers "is an ancestor of kept content" without materializing any + per-component prefix copy, so the index is O(manifest size) memory. */ +static bool build_keep_index(const ArrayList* manifest, PathIndex* index) { + if (!manifest || manifest->size <= 0) + return path_index_build(index, NULL, 0); + return path_index_build(index, (const char* const*)manifest->items, (size_t)manifest->size); +} + +static bool keep_is_dir(const PathIndex* index, const char* rel_path) { + return path_index_contains(index, rel_path) || path_index_has_descendant(index, rel_path); +} + +static bool keep_is_file(const PathIndex* index, const char* rel_path) { + return path_index_contains(index, rel_path); +} + +/* True when child_rel is, or lies below, a protected entry. A prefix "a" + 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. */ +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; + size_t prefix_len = strlen(skips[i].prefix); + if (strncmp(child_rel, skips[i].prefix, prefix_len) == 0 && + (child_rel[prefix_len] == '\0' || child_rel[prefix_len] == '/')) + return true; + } + return false; +} + +/* Per-run deletion budget and tallies. `max_delete` is the cap on the number + of entries the walker may remove (SIZE_MAX = unlimited); once it is reached + the remaining extras are counted in `skipped` and left in place, matching + rsync's partial --max-delete behavior. */ +typedef struct { + size_t max_delete; + size_t deleted; + size_t skipped; + bool limit_hit; +} DeleteBudget; + +/* True when direct children of the directory named by `rel` may be removed. + With no synchronization info (dirs == NULL) the whole tree is deletable; when + a dirs index is supplied only its exact entries are (the receive root is the + "." sentinel). */ +static bool is_synced_dir(const PathIndex* dirs, const char* rel) { + if (!dirs) + return true; + return path_index_contains(dirs, rel[0] == '\0' ? "." : rel); +} + +/* Unsigned byte-wise string compare, matching rsync's u_strcmp (a signed + strcmp would order bytes >= 0x80 differently). */ +static int delete_name_cmp(const char* a, const char* b) { + const unsigned char* pa = (const unsigned char*)a; + const unsigned char* pb = (const unsigned char*)b; + while (*pa != '\0' && *pa == *pb) { + pa++; + pb++; + } + return (int)*pa - (int)*pb; +} + +bool delete_dir_entries_collect(int dirfd, DeleteDirEntry** out, size_t* count, + bool* operation_ok) { + *out = NULL; + *count = 0; + if (operation_ok) + *operation_ok = true; + int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (scanfd < 0) + return false; + DIR* dir = fdopendir(scanfd); + if (!dir) { + close(scanfd); + return false; + } + DeleteDirEntry* entries = NULL; + size_t used = 0; + size_t capacity = 0; + bool ok = true; + const struct dirent* entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) + continue; + struct stat st; + if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) { + if (errno != ENOENT && operation_ok) + *operation_ok = false; + continue; + } + if (used == capacity) { + size_t next = capacity == 0 ? 16 : capacity * 2; + DeleteDirEntry* grown = realloc(entries, next * sizeof(*grown)); + if (!grown) { + ok = false; + break; + } + entries = grown; + capacity = next; + } + entries[used].name = str_dup(entry->d_name); + if (!entries[used].name) { + ok = false; + break; + } + entries[used].is_dir = S_ISDIR(st.st_mode); + used++; + } + closedir(dir); + if (!ok) { + delete_dir_entries_free(entries, used); + return false; + } + *out = entries; + *count = used; + return true; +} + +void delete_dir_entries_free(DeleteDirEntry* entries, size_t count) { + if (!entries) + return; + for (size_t i = 0; i < count; i++) + free(entries[i].name); + free(entries); +} + +/* rsync's extraneous-entry order: subdirectories before files, each group in + descending name order. */ +int delete_dir_entry_cmp_desc(const void* a, const void* b) { + const DeleteDirEntry* ea = a; + const DeleteDirEntry* eb = b; + if (ea->is_dir != eb->is_dir) + return ea->is_dir ? -1 : 1; + return -delete_name_cmp(ea->name, eb->name); +} + +/* rsync's kept-subdirectory order: plain ascending name. */ +int delete_dir_entry_cmp_asc(const void* a, const void* b) { + const DeleteDirEntry* ea = a; + const DeleteDirEntry* eb = b; + return delete_name_cmp(ea->name, eb->name); +} + +/* How the shared classification/descent walk disposes of an extra it has + identified. LIST records the destination-relative path without touching disk + (the -n/--dry-run would-delete enumeration); DELETE unlinks/rmdirs it, charges + the shared --max-delete budget and notifies the observer. Both modes classify + and traverse identically, so the dry-run enumeration and the real deletion + cannot drift. */ +typedef enum { DELETE_WALK_MODE_DELETE, DELETE_WALK_MODE_LIST } DeleteWalkMode; + +typedef struct { + DeleteWalkMode mode; + DeleteBudget* budget; /* DELETE mode */ + ArrayList* out; /* LIST mode: receives strdup'd relative paths */ + size_t* recorded; /* LIST mode */ + DeletePathObserver observer; /* DELETE mode */ + void* observer_context; /* DELETE mode */ +} DeleteWalkState; + +/* Remove the extras directly inside the directory open on `dirfd` (DELETE mode) + or record the paths that WOULD be removed (LIST mode), recursing into every + child directory so kept content below a synchronized prefix is reached. + `all_removed` reports whether every child entry was removed (so the caller may + rmdir this directory). A child directory is never removed when it is itself a + synchronized directory or holds kept content; with a dirs index supplied, + direct children of a non-synchronized directory are never extras at all (they + are left in place but still descended into). Symlinks are unlinked like any + other non-directory extra (never followed). + + Entries are processed in rsync's order (extraneous subdirectories in + descending name order, then extraneous files, then kept subdirectories in + ascending order) rather than readdir() order, so `--max-delete` leaves the + same survivors and the `--info=del`/dry-run line order matches rsync. */ +static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* keep, + const PathIndex* dirs, DeleteWalkState* state, + const DeleteSkipEntry* skips, int skip_count, + const FilterRuleList* protect_rules, bool parent_deletable, + bool* all_removed) { + DeleteDirEntry* entries = NULL; + size_t count = 0; + bool collect_ok = true; + if (!delete_dir_entries_collect(dirfd, &entries, &count, &collect_ok)) + return false; + bool operation_ok = collect_ok; + bool local_survives = false; + bool* shielded = calloc(count ? count : 1, sizeof(bool)); + bool* is_extra = calloc(count ? count : 1, sizeof(bool)); + if (!shielded || !is_extra) { + free(shielded); + free(is_extra); + delete_dir_entries_free(entries, count); + return false; + } + /* A directory is deletable when it or ANY ancestor is synchronized; the + `parent_deletable` flag carries that down the recursion so dest-only + directories below a synchronized root are removed wholesale. */ + bool deletable = parent_deletable || is_synced_dir(dirs, rel_path); + bool at_root = rel_path[0] == '\0'; + + /* Reproduce rsync's traversal order: extraneous subdirectories in descending + name order, then extraneous files in descending name order, and kept + subdirectories only afterwards (ascending). Sorting up front also fixes the + identity of the survivors under a partial --max-delete. */ + if (count > 1) + qsort(entries, count, sizeof(*entries), delete_dir_entry_cmp_desc); + size_t dir_count = 0; + while (dir_count < count && entries[dir_count].is_dir) + dir_count++; + + /* Classify every entry up front (the verdict does not depend on processing + order) so the ordered passes below can act on it. */ + for (size_t i = 0; i < count; i++) { + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (!child_rel) { + operation_ok = false; + continue; + } + /* A --delay-updates run keeps its staging directory as a direct child of + the receive root, and basis-dir snapshots live below it too. Their + contents are not manifest entries, so descending into them would delete + every staged / basis file as an "extra". Only the staging name (a + top-level-only prefix) and the basis prefixes are protected: a nested + destination directory that happens to be called .fastsync-stage is + ordinary content. */ + if (path_under_skip_prefix(child_rel, at_root, skips, skip_count)) { + shielded[i] = true; + local_survives = true; + } else if (protect_rules && + filter_rules_apply_side(protect_rules, child_rel, entries[i].name, entries[i].is_dir, + FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT) { + /* A first-match protect rule shields the extra; for a directory the whole + subtree is shielded (rsync prunes an excluded directory), so do not + descend. */ + shielded[i] = true; + local_survives = true; + } else if (entries[i].is_dir) { + bool child_synced = dirs && path_index_contains(dirs, child_rel); + is_extra[i] = deletable && !child_synced && !keep_is_dir(keep, child_rel); + if (!is_extra[i]) + local_survives = true; + } else { + is_extra[i] = deletable && !keep_is_file(keep, child_rel); + if (!is_extra[i]) + local_survives = true; + } + free(child_rel); + } + + /* Pass 1: extraneous subdirectories, descending. */ + for (size_t i = 0; i < dir_count; i++) { + if (!is_extra[i]) + continue; + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (!child_rel) { + operation_ok = false; + continue; + } + int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + bool child_all_removed = false; + if (childfd >= 0) { + if (!delete_walk_fd(childfd, child_rel, keep, dirs, state, skips, skip_count, protect_rules, + deletable, &child_all_removed)) + operation_ok = false; + close(childfd); + } else if (errno != ENOENT) { + operation_ok = false; + } + if (child_all_removed && deletable) { + if (state->mode == DELETE_WALK_MODE_LIST) { + /* Record the directory with rsync's trailing slash. */ + size_t len = strlen(child_rel); + char* copy = malloc(len + 2); + if (!copy) { + operation_ok = false; + } else { + memcpy(copy, child_rel, len); + copy[len] = '/'; + copy[len + 1] = '\0'; + if (!array_list_add(state->out, copy)) { + free(copy); + operation_ok = false; + } else { + (*state->recorded)++; + } + } + } else if (state->budget->deleted >= state->budget->max_delete) { + state->budget->limit_hit = true; + state->budget->skipped++; + local_survives = true; + } else if (unlinkat(dirfd, entries[i].name, AT_REMOVEDIR) != 0) { + /* ENOENT: already gone (fine). ENOTEMPTY/EEXIST: the directory still + holds entries the walker leaves in place (a protected excluded + prefix, a kept file the manifest protects, a symlink); rsync leaves + such a directory behind, so this is not an error. Only genuine I/O + failures abort the deletion. */ + if (errno != ENOENT && errno != ENOTEMPTY && errno != EEXIST) + operation_ok = false; + local_survives = true; + } else { + state->budget->deleted++; + /* rsync reports a removed directory with a trailing slash. */ + if (state->observer) { + size_t len = strlen(child_rel); + char* with_slash = malloc(len + 2); + if (with_slash) { + memcpy(with_slash, child_rel, len); + with_slash[len] = '/'; + with_slash[len + 1] = '\0'; + state->observer(state->observer_context, with_slash); + free(with_slash); + } else { + state->observer(state->observer_context, child_rel); + } + } + } + } else { + local_survives = true; + } + free(child_rel); + } + + /* Pass 2: extraneous files, descending. */ + for (size_t i = dir_count; i < count; i++) { + if (!is_extra[i]) + continue; + if (state->mode == DELETE_WALK_MODE_LIST) { + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (!child_rel) { + operation_ok = false; + continue; + } + char* copy = str_dup(child_rel); + if (!copy || !array_list_add(state->out, copy)) { + free(copy); + operation_ok = false; + } else { + (*state->recorded)++; + } + free(child_rel); + } else if (state->budget->deleted >= state->budget->max_delete) { + state->budget->limit_hit = true; + state->budget->skipped++; + local_survives = true; + } else if (unlinkat(dirfd, entries[i].name, 0) != 0) { + if (errno != ENOENT) + operation_ok = false; + local_survives = true; + } else { + state->budget->deleted++; + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (child_rel) { + if (state->observer) + state->observer(state->observer_context, child_rel); + char* escaped_path = output_escape(child_rel, log_get_8_bit_output()); + fprintf(stderr, " Deleted: %s\n", escaped_path ? escaped_path : ""); + free(escaped_path); + } + free(child_rel); + } + } + + /* Pass 3: kept subdirectories, ascending (rsync descends into these only + after the parent's own extras have been handled). */ + for (size_t i = dir_count; i-- > 0;) { + if (is_extra[i] || shielded[i]) + continue; + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (!child_rel) { + operation_ok = false; + continue; + } + int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + bool child_all_removed = false; + if (childfd >= 0) { + if (!delete_walk_fd(childfd, child_rel, keep, dirs, state, skips, skip_count, protect_rules, + deletable, &child_all_removed)) + operation_ok = false; + close(childfd); + } else if (errno != ENOENT) { + operation_ok = false; + } + /* A kept/synchronized directory is never removed. */ + local_survives = true; + free(child_rel); + } + + free(shielded); + free(is_extra); + delete_dir_entries_free(entries, count); + *all_removed = !local_survives; + return operation_ok; +} + +/* Open the receive root following the same authorized-root confinement the + walker uses, or dest_root directly when no authorized root is installed. */ +static int open_destination_root(const char* dest_root) { + int root_fd = utils_get_authorized_root_fd(); + if (root_fd >= 0) { + if (utils_get_authorized_root_path()) + return utils_open_authorized_destination(dest_root); + if (dest_root == NULL) + return dup(root_fd); + return -1; + } + return open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); +} + +bool delete_extras_list(const char* dest_root, const ArrayList* manifest, + const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count, + const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out) { + if (count_out) + *count_out = 0; + if (!manifest || !out) + return false; + PathIndex keep; + if (!build_keep_index(manifest, &keep)) + return false; + PathIndex dirs; + bool have_dirs = synced_dirs != NULL; + if (have_dirs && + !path_index_build(&dirs, (const char* const*)synced_dirs->items, (size_t)synced_dirs->size)) { + path_index_free(&keep); + return false; + } + int rootfd = open_destination_root(dest_root); + if (rootfd < 0) { + path_index_free(&keep); + if (have_dirs) + path_index_free(&dirs); + return false; + } + bool all_removed = false; + size_t recorded = 0; + DeleteWalkState state = {.mode = DELETE_WALK_MODE_LIST, + .budget = NULL, + .out = out, + .recorded = &recorded, + .observer = NULL, + .observer_context = NULL}; + bool ok = delete_walk_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &state, skips, skip_count, + protect_rules, false, &all_removed); + if (close(rootfd) != 0) + ok = false; + path_index_free(&keep); + if (have_dirs) + path_index_free(&dirs); + if (count_out) + *count_out = recorded; + return ok; +} + +DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest, + const ArrayList* synced_dirs, size_t max_delete, + const DeleteSkipEntry* skips, int skip_count, + const FilterRuleList* protect_rules, + size_t* deleted_out, size_t* skipped_out, + DeletePathObserver observer, + void* observer_context) { + if (deleted_out) + *deleted_out = 0; + if (skipped_out) + *skipped_out = 0; + if (!manifest) + return DELETE_WALK_ERROR; + /* Index the keep-set (and the synchronized-dir set, when supplied) once so + membership is answered in O(path length) instead of scanning every entry + for every destination entry. */ + PathIndex keep; + if (!build_keep_index(manifest, &keep)) + return DELETE_WALK_ERROR; + PathIndex dirs; + bool have_dirs = synced_dirs != NULL; + if (have_dirs && + !path_index_build(&dirs, (const char* const*)synced_dirs->items, (size_t)synced_dirs->size)) { + path_index_free(&keep); + return DELETE_WALK_ERROR; + } + int rootfd = open_destination_root(dest_root); + if (rootfd < 0) { + path_index_free(&keep); + if (have_dirs) + path_index_free(&dirs); + return DELETE_WALK_ERROR; + } + DeleteBudget budget = {.max_delete = max_delete, .deleted = 0, .skipped = 0, .limit_hit = false}; + bool all_removed = false; + DeleteWalkState state = {.mode = DELETE_WALK_MODE_DELETE, + .budget = &budget, + .out = NULL, + .recorded = NULL, + .observer = observer, + .observer_context = observer_context}; + bool ok = delete_walk_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &state, skips, skip_count, + protect_rules, false, &all_removed); + if (close(rootfd) != 0) + ok = false; + path_index_free(&keep); + if (have_dirs) + path_index_free(&dirs); + if (deleted_out) + *deleted_out = budget.deleted; + if (skipped_out) + *skipped_out = budget.skipped; + if (!ok) + return DELETE_WALK_ERROR; + return budget.limit_hit ? DELETE_WALK_LIMIT_REACHED : DELETE_WALK_OK; +} + +DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest, + const ArrayList* synced_dirs, size_t max_delete, + const DeleteSkipEntry* skips, int skip_count, + const FilterRuleList* protect_rules, size_t* deleted_out, + size_t* skipped_out) { + return delete_extras_limited_observed(dest_root, manifest, synced_dirs, max_delete, skips, + skip_count, protect_rules, deleted_out, skipped_out, NULL, + NULL); +} + +bool delete_extras(const char* dest_root, const ArrayList* manifest) { + return delete_extras_limited(dest_root, manifest, NULL, SIZE_MAX, NULL, 0, NULL, NULL, NULL) == + DELETE_WALK_OK; +} + +/* Build the delete-walk protection prefix for one basis directory. The walker + compares paths relative to the receive root, so a relative entry is already + in the right form; an absolute entry that lies below the root is converted to + its root-relative form, and one outside the root returns NULL (the walk + cannot reach it, and it is not protected data beneath the root). Exposed so + tests can exercise the root-of-"/" child mapping directly. */ +char* file_receive_basis_delete_relative(const Config* config, const char* path) { + if (!path) + return NULL; + if (path[0] != '/') + return str_dup(path); + const char* root = config->receive_root_directory; + if (!root || root[0] != '/') + return NULL; + size_t root_len = strlen(root); + while (root_len > 1 && root[root_len - 1] == '/') + root_len--; + if (strncmp(path, root, root_len) != 0) + return NULL; + if (root_len == 1) { + /* `root` is "/" (the only single-character absolute root): every absolute + path is below it, and the child relative form is everything after the + leading '/'. */ + if (path[1] == '\0') + return NULL; /* identical to the root, not a child */ + return str_dup(path + 1); + } + if (path[root_len] != '/') + return NULL; /* identical or a sibling sharing a name prefix */ + return str_dup(path + root_len + 1); +} + +bool delete_skips_build(const Config* config, const ArrayList* protected_paths, + const ArrayList* size_skipped, bool basis_root_relative, + DeleteSkipSet* out) { + if (!out) + return false; + out->entries = NULL; + out->owned_prefixes = NULL; + out->count = 0; + out->owned_count = 0; + if (!config) + return false; + int protected_count = protected_paths ? protected_paths->size : 0; + int size_skipped_count = size_skipped ? size_skipped->size : 0; + int count = + (config->delay_updates ? 1 : 0) + config->basis_count + protected_count + size_skipped_count; + if (count == 0) + return true; + out->entries = calloc((size_t)count, sizeof(DeleteSkipEntry)); + if (!out->entries) + return false; + if (basis_root_relative && config->basis_count > 0) { + out->owned_prefixes = calloc((size_t)config->basis_count, sizeof(char*)); + if (!out->owned_prefixes) { + free(out->entries); + out->entries = NULL; + return false; + } + out->owned_count = config->basis_count; + } + int idx = 0; + if (config->delay_updates) { + out->entries[idx].prefix = DELAY_UPDATES_STAGING_DIR; + out->entries[idx].top_level_only = true; + idx++; + } + for (int i = 0; i < config->basis_count; i++) { + const char* prefix = config->basis_dirs[i].path; + if (basis_root_relative) { + /* An absolute basis outside the receive root is unreachable by this walk, + so it contributes no protection prefix (and no slot). */ + char* relative = file_receive_basis_delete_relative(config, config->basis_dirs[i].path); + if (!relative) + continue; + out->owned_prefixes[i] = relative; + prefix = relative; + } + out->entries[idx].prefix = prefix; + out->entries[idx].top_level_only = false; + idx++; + } + for (int i = 0; i < protected_count; i++) { + out->entries[idx].prefix = (const char*)protected_paths->items[i]; + out->entries[idx].top_level_only = false; + idx++; + } + for (int i = 0; i < size_skipped_count; i++) { + out->entries[idx].prefix = (const char*)size_skipped->items[i]; + out->entries[idx].top_level_only = false; + idx++; + } + out->count = idx; + return true; +} + +void delete_skips_free(DeleteSkipSet* set) { + if (!set) + return; + if (set->owned_prefixes) { + for (int i = 0; i < set->owned_count; i++) + free(set->owned_prefixes[i]); + } + free(set->owned_prefixes); + free(set->entries); + set->entries = NULL; + set->owned_prefixes = NULL; + set->count = 0; + set->owned_count = 0; +} diff --git a/src/shared/delete.h b/src/shared/delete.h new file mode 100644 index 0000000..235db8c --- /dev/null +++ b/src/shared/delete.h @@ -0,0 +1,151 @@ +#ifndef DELETE_H +#define DELETE_H + +#include "array_list.h" +#include "config.h" +#include +#include + +/* Delete engine. + * + * This module owns destination-relative delete traversal: the ordered directory + * walker that reproduces rsync's extraneous-entry order, the skip-prefix + * protection set shared by every delete pass, and the read-only enumeration + * that mirrors the walker for -n/--dry-run. The budgeted manifest commit + * (delete_commit.c) and the per-directory delete plans (delete_plan.c) are + * built on the primitives exported here. */ + +/* Result of a bounded extra-file deletion run. */ +typedef enum { + /* Every extra entry was removed (or there were none). */ + DELETE_WALK_OK = 0, + /* The numeric cap for this run was reached before every extra was removed. + The walker removed exactly the entries the cap allowed and skipped (without + removing) the rest, matching rsync's partial --max-delete behavior. */ + DELETE_WALK_LIMIT_REACHED, + /* A traversal or unlink failure aborted the deletion (partial removal is + possible, mirroring the delete pass). */ + DELETE_WALK_ERROR +} DeleteWalkResult; + +/* One protected entry for the delete walker. When top_level_only is true the + prefix is skipped only as a DIRECT child of dest_root (the --delay-updates + staging directory, which must not hide genuine extras inside a nested + destination directory that happens to share the staging name); otherwise the + prefix is skipped at any depth (the --compare-dest/--copy-dest/--link-dest + basis trees, and the sender-side protected filter-excluded prefixes, which + are never destination content). */ +typedef struct { + const char* prefix; + bool top_level_only; +} DeleteSkipEntry; + +/* A built skip-prefix set. `entries`/`count` are what path_under_skip_prefix() + consumes. `owned_prefixes` holds any prefix strings the builder had to + allocate (root-relative basis-dir conversions); it is NULL when every prefix + is borrowed from the config or the caller's lists. Release with + delete_skips_free(). */ +typedef struct { + DeleteSkipEntry* entries; + char** owned_prefixes; + int count; + int owned_count; +} DeleteSkipSet; + +/* 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); + +/* One destination-directory entry collected up front so the delete walkers can + reproduce rsync's traversal order instead of readdir() order. rsync processes + a directory's extraneous subdirectories first (descending name, depth-first), + then its extraneous files (descending name), and only afterwards descends into + its kept subdirectories (ascending name). */ +typedef struct { + char* name; + bool is_dir; +} DeleteDirEntry; +/* Collect the entries of the directory open on `dirfd` (excluding "." and ".."), + stat'ing each with AT_SYMLINK_NOFOLLOW. On success *out is a malloc'd array of + *count entries whose names the caller frees with delete_dir_entries_free(). + Returns false on an allocation/readdir failure; a vanished entry (ENOENT) is + skipped, any other stat failure is reported through *operation_ok while the + walk continues. */ +bool delete_dir_entries_collect(int dirfd, DeleteDirEntry** out, size_t* count, bool* operation_ok); +void delete_dir_entries_free(DeleteDirEntry* entries, size_t count); +/* Sort comparators: `_desc` orders subdirectories before files and each group by + descending name (rsync's extraneous-entry order); `_asc` orders plain ascending + name (rsync's kept-subdirectory order). */ +int delete_dir_entry_cmp_desc(const void* a, const void* b); +int delete_dir_entry_cmp_asc(const void* a, const void* b); + +/* Remove files/dirs/symlinks under dest_root that are not listed in manifest + without ever descending into a protected prefix (see DeleteSkipEntry). When + `synced_dirs` is non-NULL, extras are only removed directly inside a directory + whose destination-relative path is an exact entry in that list (the receive + root is the "." sentinel); directories outside the synchronized set are still + descended into so kept content below a listed directory is preserved, but + nothing in them is removed. A NULL `synced_dirs` keeps the legacy behavior of + treating the whole destination tree as deletable. `max_delete` caps the + number of removed entries (SIZE_MAX = unlimited): the walker removes up to the + cap and returns DELETE_WALK_LIMIT_REACHED when more extras remained. + `deleted_out`/`skipped_out` optionally receive the number of entries removed + and the number skipped because of the cap. */ +DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest, + const ArrayList* synced_dirs, size_t max_delete, + const DeleteSkipEntry* skips, int skip_count, + const FilterRuleList* protect_rules, size_t* deleted_out, + size_t* skipped_out); + +/* Optional per-deletion observer: called for each destination-relative path + actually removed (a file, symlink, or directory), in removal order, so the + receiver can stream rsync's `--info=del`/`--info=remove` lines. */ +typedef void (*DeletePathObserver)(void* context, const char* rel_path); + +/* `delete_extras_limited_observed` is delete_extras_limited with an optional + * observer; the observer is invoked only for entries truly removed. When + * `protect_rules` is non-NULL its receiver-side verdict is evaluated for every + * candidate extra: a first-match PROTECT leaves the entry (and, for a + * directory, its whole subtree) in place, while RISK/NONE fall through to the + * ordinary skip-prefix/keep-set logic. */ +DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest, + const ArrayList* synced_dirs, size_t max_delete, + const DeleteSkipEntry* skips, int skip_count, + const FilterRuleList* protect_rules, + size_t* deleted_out, size_t* skipped_out, + DeletePathObserver observer, + void* observer_context); +/* Read-only companion to delete_extras_limited: walk the destination exactly as + the delete pass would and APPEND (strdup'd) destination-relative paths that + WOULD be removed, without touching disk. Used for -n/--dry-run --delete + would-delete reporting. Returns true on a clean walk; the caller owns the + strings appended to `out` and receives their count in *count_out. */ +bool delete_extras_list(const char* dest_root, const ArrayList* manifest, + const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count, + const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out); +bool delete_extras(const char* dest_root, const ArrayList* manifest); + +/* Build the delete walk's skip-prefix set from the config's --delay-updates + staging directory, its --compare-dest/--copy-dest/--link-dest basis dirs, and + the caller-supplied protection lists, in that order. `protected_paths` and + `size_skipped` are borrowed (may be NULL); every entry in them is protected at + any depth. The staging directory is protected only as a DIRECT child of the + receive root. `basis_root_relative` selects how a basis path becomes a + prefix: true converts an absolute path under the receive root to its + root-relative form (the whole-tree commit walk; an unreachable path + contributes no slot), false keeps the configured path verbatim (the + per-directory plan walk). On success the caller releases `*out` with + delete_skips_free(); returns false on allocation failure. */ +bool delete_skips_build(const Config* config, const ArrayList* protected_paths, + const ArrayList* size_skipped, bool basis_root_relative, + DeleteSkipSet* out); +void delete_skips_free(DeleteSkipSet* set); + +/* Convert one basis-directory path to the receive-root-relative protection + prefix the delete walker uses (NULL when it lies outside the root). Exposed + for unit tests of the root-of-"/" and normalization edge cases. */ +char* file_receive_basis_delete_relative(const Config* config, const char* path); + +#endif diff --git a/src/shared/delete_commit.c b/src/shared/delete_commit.c index a522dd8..07f72db 100644 --- a/src/shared/delete_commit.c +++ b/src/shared/delete_commit.c @@ -126,38 +126,6 @@ typedef struct { bool limit_hit; } DeleteBudgetState; -/* Build the delete-walk protection prefix for one basis directory. The walker - compares paths relative to the receive root, so a relative entry is already - in the right form; an absolute entry that lies below the root is converted to - its root-relative form, and one outside the root returns NULL (the walk - cannot reach it, and it is not protected data beneath the root). Exposed so - tests can exercise the root-of-"/" child mapping directly. */ -char* file_receive_basis_delete_relative(const Config* config, const char* path) { - if (!path) - return NULL; - if (path[0] != '/') - return str_dup(path); - const char* root = config->receive_root_directory; - if (!root || root[0] != '/') - return NULL; - size_t root_len = strlen(root); - while (root_len > 1 && root[root_len - 1] == '/') - root_len--; - if (strncmp(path, root, root_len) != 0) - return NULL; - if (root_len == 1) { - /* `root` is "/" (the only single-character absolute root): every absolute - path is below it, and the child relative form is everything after the - leading '/'. */ - if (path[1] == '\0') - return NULL; /* identical to the root, not a child */ - return str_dup(path + 1); - } - if (path[root_len] != '/') - return NULL; /* identical or a sibling sharing a name prefix */ - return str_dup(path + root_len + 1); -} - /* 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 @@ -174,55 +142,13 @@ static bool delete_extras_budgeted_observed(const Config* config, DeleteManifest 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; - char** owned_prefixes = NULL; - int used = 0; - if (skip_count > 0) { - skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry)); - owned_prefixes = calloc((size_t)config->basis_count, sizeof(char*)); - if (!skips || (config->basis_count > 0 && !owned_prefixes)) { - free(skips); - free(owned_prefixes); - 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++) { - /* An absolute basis outside the receive root is unreachable by this walk, - so it contributes no protection prefix (and no slot). */ - char* prefix = file_receive_basis_delete_relative(config, config->basis_dirs[i].path); - if (!prefix) - continue; - owned_prefixes[i] = prefix; - skips[idx].prefix = prefix; - 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++; - } - used = idx; - } + /* Protected entries: the --delay-updates staging name (only as a DIRECT child + of the receive root), the alternate basis directories and the sender-side + protected prefixes (filter-excluded and size-pruned source mirrors), all at + any depth. See delete_skips_build(). */ + DeleteSkipSet skips; + if (!delete_skips_build(config, manifest->protected, NULL, true, &skips)) + return false; /* Clamp rather than subtract: an accounting bug where deleted already exceeds max_delete must never underflow into an effectively unlimited budget. */ size_t remaining; @@ -235,14 +161,9 @@ static bool delete_extras_budgeted_observed(const Config* config, DeleteManifest size_t deleted = 0; size_t skipped = 0; DeleteWalkResult result = delete_extras_limited_observed( - config->receive_root_directory, manifest->keeps, manifest->dirs, remaining, skips, used, - config->protect_rules, &deleted, &skipped, observer, observer_context); - if (owned_prefixes) { - for (int i = 0; i < config->basis_count; i++) - free(owned_prefixes[i]); - } - free(owned_prefixes); - free(skips); + config->receive_root_directory, manifest->keeps, manifest->dirs, remaining, skips.entries, + skips.count, config->protect_rules, &deleted, &skipped, observer, observer_context); + delete_skips_free(&skips); budget->deleted += deleted; budget->skipped += skipped; if (result == DELETE_WALK_LIMIT_REACHED) { @@ -303,35 +224,12 @@ static bool delete_missing_args_budgeted_observed(const Config* config, DeleteMa 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; - char** owned_prefixes = NULL; - int used = 0; - if (skip_count > 0) { - skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry)); - owned_prefixes = calloc((size_t)config->basis_count, sizeof(char*)); - if (!skips || (config->basis_count > 0 && !owned_prefixes)) { - free(skips); - free(owned_prefixes); - 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++) { - char* prefix = file_receive_basis_delete_relative(config, config->basis_dirs[i].path); - if (!prefix) - continue; - owned_prefixes[i] = prefix; - skips[idx].prefix = prefix; - skips[idx].top_level_only = false; - idx++; - } - used = idx; - } + /* The staging directory and basis snapshots stay protected exactly as in the + extras walker (the missing-args path overrides the ordinary protected + prefixes, so those are not passed here). */ + DeleteSkipSet skips; + if (!delete_skips_build(config, NULL, NULL, true, &skips)) + return false; bool ok = true; for (int i = 0; i < manifest->missing->size; i++) { const char* rel = (const char*)manifest->missing->items[i]; @@ -343,7 +241,7 @@ static bool delete_missing_args_budgeted_observed(const Config* config, DeleteMa continue; } bool at_root = strchr(rel, '/') == NULL; - if (path_under_skip_prefix(rel, at_root, skips, used)) { + if (path_under_skip_prefix(rel, at_root, skips.entries, skips.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); " @@ -472,12 +370,7 @@ static bool delete_missing_args_budgeted_observed(const Config* config, DeleteMa if (!ok) break; } - if (owned_prefixes) { - for (int i = 0; i < config->basis_count; i++) - free(owned_prefixes[i]); - } - free(owned_prefixes); - free(skips); + delete_skips_free(&skips); return ok; } @@ -489,52 +382,12 @@ bool manifest_would_delete_list(const Config* config, DeleteManifest* manifest, *count_out = 0; if (!config || !manifest || !manifest->keeps || !out) return false; - int skip_count = (config->delay_updates ? 1 : 0) + config->basis_count + - (manifest->protected ? manifest->protected->size : 0); - DeleteSkipEntry* skips = NULL; - char** owned_prefixes = NULL; - int used = 0; - if (skip_count > 0) { - skips = calloc((size_t)skip_count, sizeof(DeleteSkipEntry)); - owned_prefixes = calloc((size_t)config->basis_count, sizeof(char*)); - if (!skips || (config->basis_count > 0 && !owned_prefixes)) { - free(skips); - free(owned_prefixes); - 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++) { - /* Normalize exactly like the real commit path: a relative entry is - already root-relative, an absolute one inside the receive root is - converted, and one outside contributes no protection prefix. */ - char* prefix = file_receive_basis_delete_relative(config, config->basis_dirs[i].path); - if (!prefix) - continue; - owned_prefixes[i] = prefix; - skips[idx].prefix = prefix; - 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++; - } - used = idx; - } + DeleteSkipSet skips; + if (!delete_skips_build(config, manifest->protected, NULL, true, &skips)) + return false; bool ok = delete_extras_list(config->receive_root_directory, manifest->keeps, manifest->dirs, - skips, used, config->protect_rules, out, count_out); - if (owned_prefixes) { - for (int i = 0; i < config->basis_count; i++) - free(owned_prefixes[i]); - } - free(owned_prefixes); - free(skips); + skips.entries, skips.count, config->protect_rules, out, count_out); + delete_skips_free(&skips); return ok; } diff --git a/src/shared/delete_commit.h b/src/shared/delete_commit.h index c2d374b..16f00ae 100644 --- a/src/shared/delete_commit.h +++ b/src/shared/delete_commit.h @@ -3,7 +3,7 @@ #include "array_list.h" #include "config.h" -#include "utils.h" +#include "delete.h" #include /* Delete-commit module: delete-manifest receive plus the budgeted extras and @@ -102,9 +102,5 @@ DeleteCommitResult manifest_delete_all_observed(const Config* config, DeleteMani clean walk; `*count_out` receives the number of paths appended. */ bool manifest_would_delete_list(const Config* config, DeleteManifest* manifest, ArrayList* out, size_t* count_out); -/* Convert one basis-directory path to the receive-root-relative protection - prefix the delete walker uses (NULL when it lies outside the root). Exposed - for unit tests of the root-of-"/" and normalization edge cases. */ -char* file_receive_basis_delete_relative(const Config* config, const char* path); #endif diff --git a/src/shared/delete_plan.c b/src/shared/delete_plan.c index 58eb52e..f69213b 100644 --- a/src/shared/delete_plan.c +++ b/src/shared/delete_plan.c @@ -2,6 +2,7 @@ #include "charset.h" #include "delay_updates.h" +#include "delete.h" #include "file.h" #include "log.h" #include "utils.h" @@ -601,9 +602,8 @@ static int open_plan_dir(const Config* config, const char* dir) { return fd; } -typedef struct PlanSkips { - DeleteSkipEntry* entries; - int count; +typedef struct { + DeleteSkipSet set; /* Receiver-side delete-protection rules received on the config frame (NULL when the sender sent none). Evaluated per extra so a protect/risk rule is honored under --delete-during/--delete-delay exactly like the whole-tree @@ -613,39 +613,12 @@ typedef struct PlanSkips { static bool build_plan_skips(const Config* config, const DeletePlanSession* session, PlanSkips* out) { - out->entries = NULL; - out->count = 0; out->protect_rules = config->protect_rules; - int count = (config->delay_updates ? 1 : 0) + config->basis_count + - session->protected_prefixes->size + session->size_skipped->size; - if (count == 0) - return true; - out->entries = calloc((size_t)count, sizeof(DeleteSkipEntry)); - if (!out->entries) - return false; - int idx = 0; - if (config->delay_updates) { - out->entries[idx].prefix = DELAY_UPDATES_STAGING_DIR; - out->entries[idx].top_level_only = true; - idx++; - } - for (int i = 0; i < config->basis_count; i++) { - out->entries[idx].prefix = config->basis_dirs[i].path; - out->entries[idx].top_level_only = false; - idx++; - } - for (int i = 0; i < session->protected_prefixes->size; i++) { - out->entries[idx].prefix = (const char*)session->protected_prefixes->items[i]; - out->entries[idx].top_level_only = false; - idx++; - } - for (int i = 0; i < session->size_skipped->size; i++) { - out->entries[idx].prefix = (const char*)session->size_skipped->items[i]; - out->entries[idx].top_level_only = false; - idx++; - } - out->count = idx; - return true; + /* The per-directory plan walk keeps each basis path verbatim (it does not + convert an absolute under-root path to its root-relative form, unlike the + whole-tree commit walk). */ + return delete_skips_build(config, session->protected_prefixes, session->size_skipped, false, + &out->set); } static bool budget_available(const DeletePlanSession* session) { @@ -796,7 +769,7 @@ static bool process_children(int dirfd, const char* dir_rel, const ArrayList* ke operation_ok = false; continue; } - if (path_under_skip_prefix(child_rel, at_root, skips->entries, skips->count)) { + if (path_under_skip_prefix(child_rel, at_root, skips->set.entries, skips->set.count)) { shielded[i] = true; local_survives = true; free(child_rel); @@ -883,7 +856,7 @@ static bool apply_plan_dir(DeletePlanSession* session, const Config* config, con bool survives = false; bool ok = process_children(dirfd, dir, dirs, files, strcmp(dir, ".") == 0, false, &skips, session, &survives); - free(skips.entries); + delete_skips_free(&skips.set); close(dirfd); if (!ok) log_message(LOG_LEVEL_ERROR, "deletion failed while removing extraneous files"); @@ -1024,7 +997,7 @@ static bool apply_deferred_path(DeletePlanSession* session, const Config* config } bool survives = false; bool ok = process_children(dirfd, rel, NULL, NULL, false, true, &skips, session, &survives); - free(skips.entries); + delete_skips_free(&skips.set); close(dirfd); if (!ok) { close(parent_fd); diff --git a/src/shared/delete_plan.h b/src/shared/delete_plan.h index 93929c8..9472d65 100644 --- a/src/shared/delete_plan.h +++ b/src/shared/delete_plan.h @@ -3,6 +3,7 @@ #include "array_list.h" #include "config.h" +#include "delete.h" #include "file_receive.h" #include "protocol.h" #include "utils.h" diff --git a/src/shared/utils.c b/src/shared/utils.c index 947a4d9..f758e4e 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -625,641 +625,6 @@ bool format_human_bytes(unsigned long long bytes, char* buffer, size_t buffer_si return written >= 0 && (size_t)written < buffer_size; } -/* Build the keep-set index from the exact manifest entries only. A lookup of - `rel` succeeds iff `rel` is a kept entry, a kept directory, or an ancestor - directory of kept content (the old is_dir_in_manifest predicate); the sorted - view answers "is an ancestor of kept content" without materializing any - per-component prefix copy, so the index is O(manifest size) memory. */ -static bool build_keep_index(const ArrayList* manifest, PathIndex* index) { - if (!manifest || manifest->size <= 0) - return path_index_build(index, NULL, 0); - return path_index_build(index, (const char* const*)manifest->items, (size_t)manifest->size); -} - -static bool keep_is_dir(const PathIndex* index, const char* rel_path) { - return path_index_contains(index, rel_path) || path_index_has_descendant(index, rel_path); -} - -static bool keep_is_file(const PathIndex* index, const char* rel_path) { - return path_index_contains(index, rel_path); -} - -/* True when child_rel is, or lies below, a protected entry. A prefix "a" - 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. */ -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; - size_t prefix_len = strlen(skips[i].prefix); - if (strncmp(child_rel, skips[i].prefix, prefix_len) == 0 && - (child_rel[prefix_len] == '\0' || child_rel[prefix_len] == '/')) - return true; - } - return false; -} - -/* Per-run deletion budget and tallies. `max_delete` is the cap on the number - of entries the walker may remove (SIZE_MAX = unlimited); once it is reached - the remaining extras are counted in `skipped` and left in place, matching - rsync's partial --max-delete behavior. */ -typedef struct { - size_t max_delete; - size_t deleted; - size_t skipped; - bool limit_hit; -} DeleteBudget; - -/* True when direct children of the directory named by `rel` may be removed. - With no synchronization info (dirs == NULL) the whole tree is deletable; when - a dirs index is supplied only its exact entries are (the receive root is the - "." sentinel). */ -static bool is_synced_dir(const PathIndex* dirs, const char* rel) { - if (!dirs) - return true; - return path_index_contains(dirs, rel[0] == '\0' ? "." : rel); -} - -/* Unsigned byte-wise string compare, matching rsync's u_strcmp (a signed - strcmp would order bytes >= 0x80 differently). */ -static int delete_name_cmp(const char* a, const char* b) { - const unsigned char* pa = (const unsigned char*)a; - const unsigned char* pb = (const unsigned char*)b; - while (*pa != '\0' && *pa == *pb) { - pa++; - pb++; - } - return (int)*pa - (int)*pb; -} - -bool delete_dir_entries_collect(int dirfd, DeleteDirEntry** out, size_t* count, - bool* operation_ok) { - *out = NULL; - *count = 0; - if (operation_ok) - *operation_ok = true; - int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - if (scanfd < 0) - return false; - DIR* dir = fdopendir(scanfd); - if (!dir) { - close(scanfd); - return false; - } - DeleteDirEntry* entries = NULL; - size_t used = 0; - size_t capacity = 0; - bool ok = true; - const struct dirent* entry; - while ((entry = readdir(dir)) != NULL) { - if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) - continue; - struct stat st; - if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) { - if (errno != ENOENT && operation_ok) - *operation_ok = false; - continue; - } - if (used == capacity) { - size_t next = capacity == 0 ? 16 : capacity * 2; - DeleteDirEntry* grown = realloc(entries, next * sizeof(*grown)); - if (!grown) { - ok = false; - break; - } - entries = grown; - capacity = next; - } - entries[used].name = str_dup(entry->d_name); - if (!entries[used].name) { - ok = false; - break; - } - entries[used].is_dir = S_ISDIR(st.st_mode); - used++; - } - closedir(dir); - if (!ok) { - delete_dir_entries_free(entries, used); - return false; - } - *out = entries; - *count = used; - return true; -} - -void delete_dir_entries_free(DeleteDirEntry* entries, size_t count) { - if (!entries) - return; - for (size_t i = 0; i < count; i++) - free(entries[i].name); - free(entries); -} - -/* rsync's extraneous-entry order: subdirectories before files, each group in - descending name order. */ -int delete_dir_entry_cmp_desc(const void* a, const void* b) { - const DeleteDirEntry* ea = a; - const DeleteDirEntry* eb = b; - if (ea->is_dir != eb->is_dir) - return ea->is_dir ? -1 : 1; - return -delete_name_cmp(ea->name, eb->name); -} - -/* rsync's kept-subdirectory order: plain ascending name. */ -int delete_dir_entry_cmp_asc(const void* a, const void* b) { - const DeleteDirEntry* ea = a; - const DeleteDirEntry* eb = b; - return delete_name_cmp(ea->name, eb->name); -} - -/* Remove the extras directly inside the directory open on `dirfd`, recursing - into every child directory so kept content below a synchronized prefix is - reached. `all_removed` reports whether every child entry was removed (so the - caller may rmdir this directory). A child directory is never removed when it - is itself a synchronized directory or holds kept content; with a dirs index - supplied, direct children of a non-synchronized directory are never extras at - all (they are left in place but still descended into). Symlinks are unlinked - like any other non-directory extra (never followed). - - Entries are processed in rsync's order (extraneous subdirectories in - descending name order, then extraneous files, then kept subdirectories in - ascending order) rather than readdir() order, so `--max-delete` leaves the - same survivors and the `--info=del`/dry-run line order matches rsync. */ -static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* keep, - const PathIndex* dirs, DeleteBudget* budget, - const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, bool parent_deletable, - bool* all_removed, DeletePathObserver observer, - void* observer_context) { - DeleteDirEntry* entries = NULL; - size_t count = 0; - bool collect_ok = true; - if (!delete_dir_entries_collect(dirfd, &entries, &count, &collect_ok)) - return false; - bool operation_ok = collect_ok; - bool local_survives = false; - bool* shielded = calloc(count ? count : 1, sizeof(bool)); - bool* is_extra = calloc(count ? count : 1, sizeof(bool)); - if (!shielded || !is_extra) { - free(shielded); - free(is_extra); - delete_dir_entries_free(entries, count); - return false; - } - /* A directory is deletable when it or ANY ancestor is synchronized; the - `parent_deletable` flag carries that down the recursion so dest-only - directories below a synchronized root are removed wholesale. */ - bool deletable = parent_deletable || is_synced_dir(dirs, rel_path); - bool at_root = rel_path[0] == '\0'; - - /* Reproduce rsync's traversal order: extraneous subdirectories in descending - name order, then extraneous files in descending name order, and kept - subdirectories only afterwards (ascending). Sorting up front also fixes the - identity of the survivors under a partial --max-delete. */ - if (count > 1) - qsort(entries, count, sizeof(*entries), delete_dir_entry_cmp_desc); - size_t dir_count = 0; - while (dir_count < count && entries[dir_count].is_dir) - dir_count++; - - /* Classify every entry up front (the verdict does not depend on processing - order) so the ordered passes below can act on it. */ - for (size_t i = 0; i < count; i++) { - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (!child_rel) { - operation_ok = false; - continue; - } - /* A --delay-updates run keeps its staging directory as a direct child of - the receive root, and basis-dir snapshots live below it too. Their - contents are not manifest entries, so descending into them would delete - every staged / basis file as an "extra". Only the staging name (a - top-level-only prefix) and the basis prefixes are protected: a nested - destination directory that happens to be called .fastsync-stage is - ordinary content. */ - if (path_under_skip_prefix(child_rel, at_root, skips, skip_count)) { - shielded[i] = true; - local_survives = true; - } else if (protect_rules && - filter_rules_apply_side(protect_rules, child_rel, entries[i].name, entries[i].is_dir, - FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT) { - /* A first-match protect rule shields the extra; for a directory the whole - subtree is shielded (rsync prunes an excluded directory), so do not - descend. */ - shielded[i] = true; - local_survives = true; - } else if (entries[i].is_dir) { - bool child_synced = dirs && path_index_contains(dirs, child_rel); - is_extra[i] = deletable && !child_synced && !keep_is_dir(keep, child_rel); - if (!is_extra[i]) - local_survives = true; - } else { - is_extra[i] = deletable && !keep_is_file(keep, child_rel); - if (!is_extra[i]) - local_survives = true; - } - free(child_rel); - } - - /* Pass 1: extraneous subdirectories, descending. */ - for (size_t i = 0; i < dir_count; i++) { - if (!is_extra[i]) - continue; - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (!child_rel) { - operation_ok = false; - continue; - } - int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - bool child_all_removed = false; - if (childfd >= 0) { - if (!delete_extras_fd(childfd, child_rel, keep, dirs, budget, skips, skip_count, - protect_rules, deletable, &child_all_removed, observer, - observer_context)) - operation_ok = false; - close(childfd); - } else if (errno != ENOENT) { - operation_ok = false; - } - if (child_all_removed && deletable) { - if (budget->deleted >= budget->max_delete) { - budget->limit_hit = true; - budget->skipped++; - local_survives = true; - } else if (unlinkat(dirfd, entries[i].name, AT_REMOVEDIR) != 0) { - /* ENOENT: already gone (fine). ENOTEMPTY/EEXIST: the directory still - holds entries the walker leaves in place (a protected excluded - prefix, a kept file the manifest protects, a symlink); rsync leaves - such a directory behind, so this is not an error. Only genuine I/O - failures abort the deletion. */ - if (errno != ENOENT && errno != ENOTEMPTY && errno != EEXIST) - operation_ok = false; - local_survives = true; - } else { - budget->deleted++; - /* rsync reports a removed directory with a trailing slash. */ - if (observer) { - size_t len = strlen(child_rel); - char* with_slash = malloc(len + 2); - if (with_slash) { - memcpy(with_slash, child_rel, len); - with_slash[len] = '/'; - with_slash[len + 1] = '\0'; - observer(observer_context, with_slash); - free(with_slash); - } else { - observer(observer_context, child_rel); - } - } - } - } else { - local_survives = true; - } - free(child_rel); - } - - /* Pass 2: extraneous files, descending. */ - for (size_t i = dir_count; i < count; i++) { - if (!is_extra[i]) - continue; - if (budget->deleted >= budget->max_delete) { - budget->limit_hit = true; - budget->skipped++; - local_survives = true; - } else if (unlinkat(dirfd, entries[i].name, 0) != 0) { - if (errno != ENOENT) - operation_ok = false; - local_survives = true; - } else { - budget->deleted++; - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (child_rel) { - if (observer) - observer(observer_context, child_rel); - char* escaped_path = output_escape(child_rel, log_get_8_bit_output()); - fprintf(stderr, " Deleted: %s\n", escaped_path ? escaped_path : ""); - free(escaped_path); - } - free(child_rel); - } - } - - /* Pass 3: kept subdirectories, ascending (rsync descends into these only - after the parent's own extras have been handled). */ - for (size_t i = dir_count; i-- > 0;) { - if (is_extra[i] || shielded[i]) - continue; - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (!child_rel) { - operation_ok = false; - continue; - } - int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - bool child_all_removed = false; - if (childfd >= 0) { - if (!delete_extras_fd(childfd, child_rel, keep, dirs, budget, skips, skip_count, - protect_rules, deletable, &child_all_removed, observer, - observer_context)) - operation_ok = false; - close(childfd); - } else if (errno != ENOENT) { - operation_ok = false; - } - /* A kept/synchronized directory is never removed. */ - local_survives = true; - free(child_rel); - } - - free(shielded); - free(is_extra); - delete_dir_entries_free(entries, count); - *all_removed = !local_survives; - return operation_ok; -} - -/* Read-only mirror of delete_extras_fd: records the paths that WOULD be removed - without unlinking anything. A child directory is reported after its own - reportable children (depth-first), matching the delete pass's ordering. */ -static bool list_extras_fd(int dirfd, const char* rel_path, const PathIndex* keep, - const PathIndex* dirs, ArrayList* out, size_t* recorded, - const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, bool parent_deletable, - bool* all_removed) { - DeleteDirEntry* entries = NULL; - size_t count = 0; - bool collect_ok = true; - if (!delete_dir_entries_collect(dirfd, &entries, &count, &collect_ok)) - return false; - bool operation_ok = collect_ok; - bool local_survives = false; - bool* shielded = calloc(count ? count : 1, sizeof(bool)); - bool* is_extra = calloc(count ? count : 1, sizeof(bool)); - if (!shielded || !is_extra) { - free(shielded); - free(is_extra); - delete_dir_entries_free(entries, count); - return false; - } - bool deletable = parent_deletable || is_synced_dir(dirs, rel_path); - bool at_root = rel_path[0] == '\0'; - - /* Mirror the delete walk's rsync order (extraneous subdirectories descending, - then extraneous files descending, then kept subdirectories ascending). */ - if (count > 1) - qsort(entries, count, sizeof(*entries), delete_dir_entry_cmp_desc); - size_t dir_count = 0; - while (dir_count < count && entries[dir_count].is_dir) - dir_count++; - - for (size_t i = 0; i < count; i++) { - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (!child_rel) { - operation_ok = false; - continue; - } - if (path_under_skip_prefix(child_rel, at_root, skips, skip_count)) { - shielded[i] = true; - local_survives = true; - } else if (protect_rules && - filter_rules_apply_side(protect_rules, child_rel, entries[i].name, entries[i].is_dir, - FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT) { - /* Mirror the delete walk: a protected entry is never reported as a - would-delete and a protected directory's subtree is not enumerated. */ - shielded[i] = true; - local_survives = true; - } else if (entries[i].is_dir) { - bool child_synced = dirs && path_index_contains(dirs, child_rel); - is_extra[i] = deletable && !child_synced && !keep_is_dir(keep, child_rel); - if (!is_extra[i]) - local_survives = true; - } else { - is_extra[i] = deletable && !keep_is_file(keep, child_rel); - if (!is_extra[i]) - local_survives = true; - } - free(child_rel); - } - - /* Pass 1: extraneous subdirectories, descending (recorded after contents). */ - for (size_t i = 0; i < dir_count; i++) { - if (!is_extra[i]) - continue; - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (!child_rel) { - operation_ok = false; - continue; - } - int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - bool child_all_removed = false; - if (childfd >= 0) { - if (!list_extras_fd(childfd, child_rel, keep, dirs, out, recorded, skips, skip_count, - protect_rules, deletable, &child_all_removed)) - operation_ok = false; - close(childfd); - } else if (errno != ENOENT) { - operation_ok = false; - } - if (child_all_removed && deletable) { - size_t len = strlen(child_rel); - char* copy = malloc(len + 2); - if (!copy) { - operation_ok = false; - } else { - memcpy(copy, child_rel, len); - copy[len] = '/'; - copy[len + 1] = '\0'; - if (!array_list_add(out, copy)) { - free(copy); - operation_ok = false; - } else { - (*recorded)++; - } - } - } else { - local_survives = true; - } - free(child_rel); - } - - /* Pass 2: extraneous files, descending. */ - for (size_t i = dir_count; i < count; i++) { - if (!is_extra[i]) - continue; - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (!child_rel) { - operation_ok = false; - continue; - } - char* copy = str_dup(child_rel); - if (!copy || !array_list_add(out, copy)) { - free(copy); - operation_ok = false; - } else { - (*recorded)++; - } - free(child_rel); - } - - /* Pass 3: kept subdirectories, ascending. */ - for (size_t i = dir_count; i-- > 0;) { - if (is_extra[i] || shielded[i]) - continue; - char* child_rel = path_cat((char*)rel_path, entries[i].name); - if (!child_rel) { - operation_ok = false; - continue; - } - int childfd = openat(dirfd, entries[i].name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - bool child_all_removed = false; - if (childfd >= 0) { - if (!list_extras_fd(childfd, child_rel, keep, dirs, out, recorded, skips, skip_count, - protect_rules, deletable, &child_all_removed)) - operation_ok = false; - close(childfd); - } else if (errno != ENOENT) { - operation_ok = false; - } - local_survives = true; - free(child_rel); - } - - free(shielded); - free(is_extra); - delete_dir_entries_free(entries, count); - *all_removed = !local_survives; - return operation_ok; -} - -bool delete_extras_list(const char* dest_root, const ArrayList* manifest, - const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out) { - if (count_out) - *count_out = 0; - if (!manifest || !out) - return false; - PathIndex keep; - if (!build_keep_index(manifest, &keep)) - return false; - PathIndex dirs; - bool have_dirs = synced_dirs != NULL; - if (have_dirs && - !path_index_build(&dirs, (const char* const*)synced_dirs->items, (size_t)synced_dirs->size)) { - path_index_free(&keep); - return false; - } - int rootfd; - int root_fd = utils_get_authorized_root_fd(); - if (root_fd >= 0) { - if (utils_get_authorized_root_path()) - rootfd = utils_open_authorized_destination(dest_root); - else if (dest_root == NULL) - rootfd = dup(root_fd); - else - rootfd = -1; - } else { - rootfd = open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - } - if (rootfd < 0) { - path_index_free(&keep); - if (have_dirs) - path_index_free(&dirs); - return false; - } - bool all_removed = false; - size_t recorded = 0; - bool ok = list_extras_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, out, &recorded, skips, - skip_count, protect_rules, false, &all_removed); - if (close(rootfd) != 0) - ok = false; - path_index_free(&keep); - if (have_dirs) - path_index_free(&dirs); - if (count_out) - *count_out = recorded; - return ok; -} - -DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest, - const ArrayList* synced_dirs, size_t max_delete, - const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, - size_t* deleted_out, size_t* skipped_out, - DeletePathObserver observer, - void* observer_context) { - if (deleted_out) - *deleted_out = 0; - if (skipped_out) - *skipped_out = 0; - if (!manifest) - return DELETE_WALK_ERROR; - /* Index the keep-set (and the synchronized-dir set, when supplied) once so - membership is answered in O(path length) instead of scanning every entry - for every destination entry. */ - PathIndex keep; - if (!build_keep_index(manifest, &keep)) - return DELETE_WALK_ERROR; - PathIndex dirs; - bool have_dirs = synced_dirs != NULL; - if (have_dirs && - !path_index_build(&dirs, (const char* const*)synced_dirs->items, (size_t)synced_dirs->size)) { - path_index_free(&keep); - return DELETE_WALK_ERROR; - } - int rootfd; - int root_fd = utils_get_authorized_root_fd(); - if (root_fd >= 0) { - if (utils_get_authorized_root_path()) - rootfd = utils_open_authorized_destination(dest_root); - else if (dest_root == NULL) - rootfd = dup(root_fd); - else - rootfd = -1; - } else { - rootfd = open(dest_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - } - if (rootfd < 0) { - path_index_free(&keep); - if (have_dirs) - path_index_free(&dirs); - return DELETE_WALK_ERROR; - } - DeleteBudget budget = {.max_delete = max_delete, .deleted = 0, .skipped = 0, .limit_hit = false}; - bool all_removed = false; - bool ok = - delete_extras_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &budget, skips, skip_count, - protect_rules, false, &all_removed, observer, observer_context); - if (close(rootfd) != 0) - ok = false; - path_index_free(&keep); - if (have_dirs) - path_index_free(&dirs); - if (deleted_out) - *deleted_out = budget.deleted; - if (skipped_out) - *skipped_out = budget.skipped; - if (!ok) - return DELETE_WALK_ERROR; - return budget.limit_hit ? DELETE_WALK_LIMIT_REACHED : DELETE_WALK_OK; -} - -DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest, - const ArrayList* synced_dirs, size_t max_delete, - const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, size_t* deleted_out, - size_t* skipped_out) { - return delete_extras_limited_observed(dest_root, manifest, synced_dirs, max_delete, skips, - skip_count, protect_rules, deleted_out, skipped_out, NULL, - NULL); -} - -bool delete_extras(const char* dest_root, const ArrayList* manifest) { - return delete_extras_limited(dest_root, manifest, NULL, SIZE_MAX, NULL, 0, NULL, NULL, NULL) == - DELETE_WALK_OK; -} - bool has_path_traversal(const char* path) { if (!path) return true; diff --git a/src/shared/utils.h b/src/shared/utils.h index aedb9ce..492200f 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -106,101 +106,7 @@ int env_choice_first(const char* env_name, int (*resolve)(const char*), bool* sp ssize_t utils_getdelim_bounded(FILE* stream, char** line, size_t* cap, int delim, size_t max_len); char* path_cat(const char* path1, const char* path2); bool glob_match(const char* pattern, const char* str); -/* Result of a bounded extra-file deletion run. */ -typedef enum { - /* Every extra entry was removed (or there were none). */ - DELETE_WALK_OK = 0, - /* The numeric cap for this run was reached before every extra was removed. - The walker removed exactly the entries the cap allowed and skipped (without - removing) the rest, matching rsync's partial --max-delete behavior. */ - DELETE_WALK_LIMIT_REACHED, - /* A traversal or unlink failure aborted the deletion (partial removal is - possible, mirroring the delete pass). */ - DELETE_WALK_ERROR -} DeleteWalkResult; -/* One protected entry for the delete walker. When top_level_only is true the - prefix is skipped only as a DIRECT child of dest_root (the --delay-updates - staging directory, which must not hide genuine extras inside a nested - destination directory that happens to share the staging name); otherwise the - prefix is skipped at any depth (the --compare-dest/--copy-dest/--link-dest - basis trees, and the sender-side protected filter-excluded prefixes, which - are never destination content). */ -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); -/* One destination-directory entry collected up front so the delete walkers can - reproduce rsync's traversal order instead of readdir() order. rsync processes - a directory's extraneous subdirectories first (descending name, depth-first), - then its extraneous files (descending name), and only afterwards descends into - its kept subdirectories (ascending name). */ -typedef struct { - char* name; - bool is_dir; -} DeleteDirEntry; -/* Collect the entries of the directory open on `dirfd` (excluding "." and ".."), - stat'ing each with AT_SYMLINK_NOFOLLOW. On success *out is a malloc'd array of - *count entries whose names the caller frees with delete_dir_entries_free(). - Returns false on an allocation/readdir failure; a vanished entry (ENOENT) is - skipped, any other stat failure is reported through *operation_ok while the - walk continues. */ -bool delete_dir_entries_collect(int dirfd, DeleteDirEntry** out, size_t* count, bool* operation_ok); -void delete_dir_entries_free(DeleteDirEntry* entries, size_t count); -/* Sort comparators: `_desc` orders subdirectories before files and each group by - descending name (rsync's extraneous-entry order); `_asc` orders plain ascending - name (rsync's kept-subdirectory order). */ -int delete_dir_entry_cmp_desc(const void* a, const void* b); -int delete_dir_entry_cmp_asc(const void* a, const void* b); -/* Remove files/dirs/symlinks under dest_root that are not listed in manifest - without ever descending into a protected prefix (see DeleteSkipEntry). When - `synced_dirs` is non-NULL, extras are only removed directly inside a directory - whose destination-relative path is an exact entry in that list (the receive - root is the "." sentinel); directories outside the synchronized set are still - descended into so kept content below a listed directory is preserved, but - nothing in them is removed. A NULL `synced_dirs` keeps the legacy behavior of - treating the whole destination tree as deletable. `max_delete` caps the - number of removed entries (SIZE_MAX = unlimited): the walker removes up to the - cap and returns DELETE_WALK_LIMIT_REACHED when more extras remained. - `deleted_out`/`skipped_out` optionally receive the number of entries removed - and the number skipped because of the cap. */ -DeleteWalkResult delete_extras_limited(const char* dest_root, const ArrayList* manifest, - const ArrayList* synced_dirs, size_t max_delete, - const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, size_t* deleted_out, - size_t* skipped_out); -/* Optional per-deletion observer: called for each destination-relative path - actually removed (a file, symlink, or directory), in removal order, so the - receiver can stream rsync's `--info=del`/`--info=remove` lines. */ -typedef void (*DeletePathObserver)(void* context, const char* rel_path); - -/* `delete_extras_limited_observed` is delete_extras_limited with an optional - * observer; the observer is invoked only for entries truly removed. When - * `protect_rules` is non-NULL its receiver-side verdict is evaluated for every - * candidate extra: a first-match PROTECT leaves the entry (and, for a - * directory, its whole subtree) in place, while RISK/NONE fall through to the - * ordinary skip-prefix/keep-set logic. */ -DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const ArrayList* manifest, - const ArrayList* synced_dirs, size_t max_delete, - const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, - size_t* deleted_out, size_t* skipped_out, - DeletePathObserver observer, - void* observer_context); -/* Read-only companion to delete_extras_limited: walk the destination exactly as - the delete pass would and APPEND (strdup'd) destination-relative paths that - WOULD be removed, without touching disk. Used for -n/--dry-run --delete - would-delete reporting. Returns true on a clean walk; the caller owns the - strings appended to `out` and receives their count in *count_out. */ -bool delete_extras_list(const char* dest_root, const ArrayList* manifest, - const ArrayList* synced_dirs, const DeleteSkipEntry* skips, int skip_count, - const FilterRuleList* protect_rules, ArrayList* out, size_t* count_out); -bool delete_extras(const char* dest_root, const ArrayList* manifest); /* Open the existing destination directory at `dest_root`, confined to the authorized root with an O_NOFOLLOW component walk (the same confinement the deletion walker uses for its root). Returns a new fd the caller owns, or -1 diff --git a/tests/test_shared_utils.c b/tests/test_shared_utils.c index b822f1e..5fafa7e 100644 --- a/tests/test_shared_utils.c +++ b/tests/test_shared_utils.c @@ -1,4 +1,5 @@ #include "test_shared_utils.h" +#include "delete.h" #include "utils.h" #include "protocol.h" #include "test_utils.h"