diff --git a/src/shared/delete.c b/src/shared/delete.c index 942ec1c..8184d01 100644 --- a/src/shared/delete.c +++ b/src/shared/delete.c @@ -224,6 +224,235 @@ typedef struct { void* observer_context; /* DELETE mode */ } DeleteWalkState; +/* The per-walk invariants threaded unchanged through every recursive descent: + the keep/synchronized-dir indexes, the destination mode and the protection + rules. Bundling them keeps the recursive helpers below to a handful of + positional arguments. */ +typedef struct { + const PathIndex* keep; + const PathIndex* dirs; + DeleteWalkState* state; + const DeleteSkipEntry* skips; + int skip_count; + const DeleteProtectRules* protect; +} DeleteWalkContext; + +/* Duplicate `path` with rsync's trailing-slash convention, used to report a + removed (or would-be-removed) directory. Returns NULL on allocation + failure. */ +static char* with_trailing_slash(const char* path) { + size_t len = strlen(path); + char* copy = malloc(len + 2); + if (!copy) + return NULL; + memcpy(copy, path, len); + copy[len] = '/'; + copy[len + 1] = '\0'; + return copy; +} + +/* Forward declaration: the ordered passes below recurse through the driver. */ +static bool delete_walk_fd(int dirfd, const char* rel_path, const DeleteWalkContext* ctx, + bool parent_deletable, bool* all_removed); + +/* Descend into the child directory `name` of `dirfd`, walking it as part of the + current operation. Returns false on a genuine open/walk failure; on success + *child_all_removed reports whether the child removed everything it held (so + the caller may rmdir it). */ +static bool delete_walk_child(int dirfd, const char* name, const char* child_rel, + const DeleteWalkContext* ctx, bool deletable, + bool* child_all_removed) { + *child_all_removed = false; + int childfd = openat(dirfd, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (childfd < 0) + return errno == ENOENT; + bool ok = delete_walk_fd(childfd, child_rel, ctx, deletable, child_all_removed); + close(childfd); + return ok; +} + +/* Classify every entry up front (the verdict does not depend on processing + order) so the ordered passes below can act on it. Sets shielded[]/is_extra[] + and reports through *local_survives whether anything in this directory stays + in place. Returns false on a path-construction failure. */ +static bool delete_walk_classify(const char* rel_path, const DeleteDirEntry* entries, size_t count, + const DeleteWalkContext* ctx, bool deletable, bool at_root, + bool* shielded, bool* is_extra, bool* local_survives) { + bool ok = true; + for (size_t i = 0; i < count; i++) { + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (!child_rel) { + 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, ctx->skips, ctx->skip_count)) { + shielded[i] = true; + *local_survives = true; + } else if (delete_protect_verdict(ctx->protect, child_rel, entries[i].name, + entries[i].is_dir) == 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 = ctx->dirs && path_index_contains(ctx->dirs, child_rel); + is_extra[i] = deletable && !child_synced && !keep_is_dir(ctx->keep, child_rel); + if (!is_extra[i]) + *local_survives = true; + } else { + is_extra[i] = deletable && !keep_is_file(ctx->keep, child_rel); + if (!is_extra[i]) + *local_survives = true; + } + free(child_rel); + } + return ok; +} + +/* Pass 1: extraneous subdirectories, descending. Recurses into each and, when + the child removed everything it held, records or removes it and charges the + budget. */ +static bool delete_walk_extra_dirs(int dirfd, const char* rel_path, const DeleteDirEntry* entries, + size_t dir_count, const DeleteWalkContext* ctx, bool deletable, + const bool* is_extra, bool* local_survives) { + bool ok = true; + 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) { + ok = false; + continue; + } + bool child_all_removed = false; + if (!delete_walk_child(dirfd, entries[i].name, child_rel, ctx, deletable, &child_all_removed)) + ok = false; + if (child_all_removed && deletable) { + if (ctx->state->mode == DELETE_WALK_MODE_LIST) { + /* Record the directory with rsync's trailing slash. */ + char* copy = with_trailing_slash(child_rel); + if (!copy) { + ok = false; + } else if (!array_list_add(ctx->state->out, copy)) { + free(copy); + ok = false; + } else { + (*ctx->state->recorded)++; + } + } else if (ctx->state->budget->deleted >= ctx->state->budget->max_delete) { + ctx->state->budget->limit_hit = true; + ctx->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) + ok = false; + *local_survives = true; + } else { + ctx->state->budget->deleted++; + /* rsync reports a removed directory with a trailing slash. */ + if (ctx->state->observer) { + char* with_slash = with_trailing_slash(child_rel); + if (with_slash) { + ctx->state->observer(ctx->state->observer_context, with_slash, DELETE_ENTRY_DIR); + free(with_slash); + } else { + ctx->state->observer(ctx->state->observer_context, child_rel, DELETE_ENTRY_DIR); + } + } + } + } else { + *local_survives = true; + } + free(child_rel); + } + return ok; +} + +/* Pass 2: extraneous files, descending. */ +static bool delete_walk_extra_files(int dirfd, const char* rel_path, const DeleteDirEntry* entries, + size_t dir_count, size_t count, const DeleteWalkContext* ctx, + const bool* is_extra, bool* local_survives) { + bool ok = true; + for (size_t i = dir_count; i < count; i++) { + if (!is_extra[i]) + continue; + if (ctx->state->mode == DELETE_WALK_MODE_LIST) { + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (!child_rel) { + ok = false; + continue; + } + char* copy = str_dup(child_rel); + if (!copy || !array_list_add(ctx->state->out, copy)) { + free(copy); + ok = false; + } else { + (*ctx->state->recorded)++; + } + free(child_rel); + } else if (ctx->state->budget->deleted >= ctx->state->budget->max_delete) { + ctx->state->budget->limit_hit = true; + ctx->state->budget->skipped++; + *local_survives = true; + } else if (unlinkat(dirfd, entries[i].name, 0) != 0) { + if (errno != ENOENT) + ok = false; + *local_survives = true; + } else { + ctx->state->budget->deleted++; + char* child_rel = path_cat((char*)rel_path, entries[i].name); + if (child_rel) { + if (ctx->state->observer) + ctx->state->observer(ctx->state->observer_context, child_rel, + delete_entry_type_of_mode(entries[i].mode)); + 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); + } + } + return ok; +} + +/* Pass 3: kept subdirectories, ascending (rsync descends into these only after + the parent's own extras have been handled). */ +static bool delete_walk_kept_dirs(int dirfd, const char* rel_path, const DeleteDirEntry* entries, + size_t dir_count, const DeleteWalkContext* ctx, bool deletable, + const bool* is_extra, const bool* shielded, + bool* local_survives) { + bool ok = true; + 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) { + ok = false; + continue; + } + bool child_all_removed = false; + if (!delete_walk_child(dirfd, entries[i].name, child_rel, ctx, deletable, &child_all_removed)) + ok = false; + /* A kept/synchronized directory is never removed. */ + *local_survives = true; + free(child_rel); + } + return ok; +} + /* 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. @@ -238,11 +467,8 @@ typedef struct { 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 DeleteProtectRules* protect, bool parent_deletable, - bool* all_removed) { +static bool delete_walk_fd(int dirfd, const char* rel_path, const DeleteWalkContext* ctx, + bool parent_deletable, bool* all_removed) { DeleteDirEntry* entries = NULL; size_t count = 0; bool collect_ok = true; @@ -261,7 +487,7 @@ static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* kee /* 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 deletable = parent_deletable || is_synced_dir(ctx->dirs, rel_path); bool at_root = rel_path[0] == '\0'; /* Reproduce rsync's traversal order: extraneous subdirectories in descending @@ -274,182 +500,18 @@ static bool delete_walk_fd(int dirfd, const char* rel_path, const PathIndex* kee 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 (delete_protect_verdict(protect, child_rel, entries[i].name, entries[i].is_dir) == - 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, - 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, DELETE_ENTRY_DIR); - free(with_slash); - } else { - state->observer(state->observer_context, child_rel, DELETE_ENTRY_DIR); - } - } - } - } 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, - delete_entry_type_of_mode(entries[i].mode)); - 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, - 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); - } + if (!delete_walk_classify(rel_path, entries, count, ctx, deletable, at_root, shielded, is_extra, + &local_survives)) + operation_ok = false; + if (!delete_walk_extra_dirs(dirfd, rel_path, entries, dir_count, ctx, deletable, is_extra, + &local_survives)) + operation_ok = false; + if (!delete_walk_extra_files(dirfd, rel_path, entries, dir_count, count, ctx, is_extra, + &local_survives)) + operation_ok = false; + if (!delete_walk_kept_dirs(dirfd, rel_path, entries, dir_count, ctx, deletable, is_extra, + shielded, &local_survives)) + operation_ok = false; free(shielded); free(is_extra); @@ -504,8 +566,13 @@ bool delete_extras_list(const char* dest_root, const ArrayList* manifest, .recorded = &recorded, .observer = NULL, .observer_context = NULL}; - bool ok = delete_walk_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &state, skips, skip_count, - protect, false, &all_removed); + DeleteWalkContext ctx = {.keep = &keep, + .dirs = have_dirs ? &dirs : NULL, + .state = &state, + .skips = skips, + .skip_count = skip_count, + .protect = protect}; + bool ok = delete_walk_fd(rootfd, "", &ctx, false, &all_removed); if (close(rootfd) != 0) ok = false; path_index_free(&keep); @@ -557,8 +624,13 @@ DeleteWalkResult delete_extras_limited_observed(const char* dest_root, const Arr .recorded = NULL, .observer = observer, .observer_context = observer_context}; - bool ok = delete_walk_fd(rootfd, "", &keep, have_dirs ? &dirs : NULL, &state, skips, skip_count, - protect, false, &all_removed); + DeleteWalkContext ctx = {.keep = &keep, + .dirs = have_dirs ? &dirs : NULL, + .state = &state, + .skips = skips, + .skip_count = skip_count, + .protect = protect}; + bool ok = delete_walk_fd(rootfd, "", &ctx, false, &all_removed); if (close(rootfd) != 0) ok = false; path_index_free(&keep); diff --git a/src/shared/delete_commit.c b/src/shared/delete_commit.c index 1360486..3f6e5d4 100644 --- a/src/shared/delete_commit.c +++ b/src/shared/delete_commit.c @@ -227,6 +227,166 @@ static void prefixed_delete_observer(void* context, const char* rel, DeleteEntry --max-delete budget: once it is exhausted the remaining requests are skipped and counted. Returns false only on a genuine error (a confinement failure on a validated path or an I/O error), which fails the run. */ + +/* How one missing-args request leaves the driver loop. The original walker + `continue`s past an invalid/protected/absent/budget-skipped request (without + breaking) but stops after a request that ran to completion while an error is + pending; NEXT/STOP preserve that control flow exactly. */ +typedef enum { MISSING_ARG_NEXT, MISSING_ARG_STOP } MissingArgStep; + +/* Remove a NON-empty missing-args directory recursively (--delete/--force in + effect): walk its contents through the budgeted extras walker so every removed + file/dir counts toward --max-delete (rsync parity), then remove the now-empty + directory itself, which costs one more budget unit. A run that hits the cap + leaves the remaining entries in place. The observer is wrapped so the nested + walk reports receive-root-relative paths. Sets the *removed and *ok outputs. */ +static void delete_nonempty_missing_dir(const char* full, const char* rel, + DeleteBudgetState* budget, DeletePathObserver observer, + void* observer_context, bool* removed, bool* ok) { + ArrayList* no_keeps = array_list_create(free); + /* Never let an accounting slip (deleted > max_delete) underflow the remaining + budget into SIZE_MAX, which would grant unlimited deletions. */ + size_t remaining = + budget->deleted >= budget->max_delete ? 0 : budget->max_delete - budget->deleted; + size_t contents_deleted = 0; + size_t contents_skipped = 0; + PrefixedDeleteObserver nested = {observer, observer_context, rel}; + DeleteWalkResult walk = + no_keeps ? delete_extras_limited_observed(full, no_keeps, NULL, remaining, NULL, 0, NULL, + &contents_deleted, &contents_skipped, + observer ? prefixed_delete_observer : NULL, + observer ? &nested : NULL) + : DELETE_WALK_ERROR; + if (no_keeps) + array_list_delete(no_keeps); + budget->deleted += contents_deleted; + budget->skipped += contents_skipped; + if (walk == DELETE_WALK_LIMIT_REACHED) { + budget->limit_hit = true; + } else if (walk != DELETE_WALK_OK) { + *ok = false; + } else if (budget->deleted >= budget->max_delete) { + budget->limit_hit = true; + budget->skipped++; + } else if (file_remove_tree_secure(full)) { + /* The shared `if (removed)` tail charges this directory exactly once; + counting it here too would consume two budget units. */ + *removed = true; + } else { + *ok = false; + } +} + +/* Remove one missing-args destination mirror. `skips` holds the receiver + artifacts (staging directory, basis snapshots) that stay protected. Returns + MISSING_ARG_STOP when the driver loop must stop (a completed removal left a + genuine error pending) and MISSING_ARG_NEXT otherwise; *ok accumulates the + overall success across the whole run. */ +static MissingArgStep delete_one_missing_arg(const Config* config, const char* rel, + const DeleteSkipSet* skips, DeleteBudgetState* budget, + DeletePathObserver observer, void* observer_context, + bool* ok) { + 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; + return MISSING_ARG_NEXT; + } + bool at_root = strchr(rel, '/') == NULL; + 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); " + "not deleting", + escaped ? escaped : ""); + free(escaped); + return MISSING_ARG_NEXT; + } + char* full = path_cat(config->receive_root_directory, rel); + if (!full) { + *ok = false; + return MISSING_ARG_NEXT; + } + char* leaf = NULL; + int parent_fd = file_open_secure_parent(full, &leaf, false); + if (parent_fd < 0) { + /* The mirror's parent directory may itself not exist on the destination + (a deeper missing entry whose leading directories were never created). + That is a no-op -- there is nothing to delete -- matching + file_remove_tree_secure's absent-path handling; only a genuine I/O + error (EACCES, a symlink loop, ...) fails the run. */ + bool absent = errno == ENOENT || errno == ENOTDIR; + free(full); + free(leaf); + if (!absent) + *ok = false; + return MISSING_ARG_NEXT; + } + struct stat st; + if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0) { + /* Already absent: nothing to delete (a no-op, not a deletion). */ + if (errno != ENOENT) + *ok = false; + close(parent_fd); + free(leaf); + free(full); + return MISSING_ARG_NEXT; + } + /* An entry that exists is one deletion: skip it (and count it) when the + shared --max-delete budget is already exhausted. */ + if (budget->deleted >= budget->max_delete) { + budget->limit_hit = true; + budget->skipped++; + close(parent_fd); + free(leaf); + free(full); + return MISSING_ARG_NEXT; + } + bool removed = false; + if (S_ISDIR(st.st_mode)) { + if (unlinkat(parent_fd, leaf, AT_REMOVEDIR) == 0) { + removed = true; + } else if (errno == ENOTEMPTY || errno == EEXIST) { + close(parent_fd); + parent_fd = -1; + free(leaf); + leaf = NULL; + if (config->use_delete || config->force_delete) { + delete_nonempty_missing_dir(full, rel, budget, observer, observer_context, &removed, ok); + } else { + char* escaped = output_escape(rel, log_get_8_bit_output()); + log_message(LOG_LEVEL_WARNING, + "missing-args destination '%s' is a non-empty directory; use --force or " + "--delete to remove it", + escaped ? escaped : ""); + free(escaped); + } + } else if (errno != ENOENT) { + *ok = false; + } + } else { + if (unlinkat(parent_fd, leaf, 0) == 0) { + removed = true; + } else if (errno != ENOENT) { + *ok = false; + } + } + if (removed) { + budget->deleted++; + if (observer) + observer(observer_context, rel, delete_entry_type_of_mode(st.st_mode)); + char* escaped = output_escape(rel, log_get_8_bit_output()); + fprintf(stderr, " Deleted: %s\n", escaped ? escaped : ""); + free(escaped); + } + if (parent_fd >= 0) + close(parent_fd); + free(leaf); + free(full); + return *ok ? MISSING_ARG_NEXT : MISSING_ARG_STOP; +} + static bool delete_missing_args_budgeted_observed(const Config* config, const DeleteManifest* manifest, DeleteBudgetState* budget, @@ -246,141 +406,8 @@ static bool delete_missing_args_budgeted_observed(const Config* config, 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.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); " - "not deleting", - escaped ? escaped : ""); - free(escaped); - continue; - } - char* full = path_cat(config->receive_root_directory, rel); - if (!full) { - ok = false; - continue; - } - char* leaf = NULL; - int parent_fd = file_open_secure_parent(full, &leaf, false); - if (parent_fd < 0) { - /* The mirror's parent directory may itself not exist on the destination - (a deeper missing entry whose leading directories were never created). - That is a no-op -- there is nothing to delete -- matching - file_remove_tree_secure's absent-path handling; only a genuine I/O - error (EACCES, a symlink loop, ...) fails the run. */ - bool absent = errno == ENOENT || errno == ENOTDIR; - free(full); - free(leaf); - if (!absent) - ok = false; - continue; - } - struct stat st; - if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0) { - /* Already absent: nothing to delete (a no-op, not a deletion). */ - if (errno != ENOENT) - ok = false; - close(parent_fd); - free(leaf); - free(full); - continue; - } - /* An entry that exists is one deletion: skip it (and count it) when the - shared --max-delete budget is already exhausted. */ - if (budget->deleted >= budget->max_delete) { - budget->limit_hit = true; - budget->skipped++; - close(parent_fd); - free(leaf); - free(full); - continue; - } - bool removed = false; - if (S_ISDIR(st.st_mode)) { - if (unlinkat(parent_fd, leaf, AT_REMOVEDIR) == 0) { - removed = true; - } else if (errno == ENOTEMPTY || errno == EEXIST) { - close(parent_fd); - parent_fd = -1; - free(leaf); - leaf = NULL; - if (config->use_delete || config->force_delete) { - /* Remove the contents entry-by-entry through the budgeted extras - walker so every deleted file/dir counts toward --max-delete (rsync - parity); the now-empty directory itself costs one more. A run that - hits the cap leaves the remaining entries in place. */ - ArrayList* no_keeps = array_list_create(free); - /* Never let an accounting slip (deleted > max_delete) underflow the - remaining budget into SIZE_MAX, which would grant unlimited - deletions. */ - size_t remaining = - budget->deleted >= budget->max_delete ? 0 : budget->max_delete - budget->deleted; - size_t contents_deleted = 0; - size_t contents_skipped = 0; - PrefixedDeleteObserver nested = {observer, observer_context, rel}; - DeleteWalkResult walk = - no_keeps ? delete_extras_limited_observed(full, no_keeps, NULL, remaining, NULL, 0, - NULL, &contents_deleted, &contents_skipped, - observer ? prefixed_delete_observer : NULL, - observer ? &nested : NULL) - : DELETE_WALK_ERROR; - if (no_keeps) - array_list_delete(no_keeps); - budget->deleted += contents_deleted; - budget->skipped += contents_skipped; - if (walk == DELETE_WALK_LIMIT_REACHED) { - budget->limit_hit = true; - } else if (walk != DELETE_WALK_OK) { - ok = false; - } else if (budget->deleted >= budget->max_delete) { - budget->limit_hit = true; - budget->skipped++; - } else if (file_remove_tree_secure(full)) { - /* The shared `if (removed)` tail charges this directory exactly - once; counting it here too would consume two budget units. */ - removed = true; - } else { - ok = false; - } - } else { - char* escaped = output_escape(rel, log_get_8_bit_output()); - log_message(LOG_LEVEL_WARNING, - "missing-args destination '%s' is a non-empty directory; use --force or " - "--delete to remove it", - escaped ? escaped : ""); - free(escaped); - } - } else if (errno != ENOENT) { - ok = false; - } - } else { - if (unlinkat(parent_fd, leaf, 0) == 0) { - removed = true; - } else if (errno != ENOENT) { - ok = false; - } - } - if (removed) { - budget->deleted++; - if (observer) - observer(observer_context, rel, delete_entry_type_of_mode(st.st_mode)); - char* escaped = output_escape(rel, log_get_8_bit_output()); - fprintf(stderr, " Deleted: %s\n", escaped ? escaped : ""); - free(escaped); - } - if (parent_fd >= 0) - close(parent_fd); - free(leaf); - free(full); - if (!ok) + if (delete_one_missing_arg(config, rel, &skips, budget, observer, observer_context, &ok) == + MISSING_ARG_STOP) break; } delete_skips_free(&skips);