From 9691dba6f0a58b6c81f17b0fa78072641838ebf8 Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 20 Sep 2026 13:15:04 +0200 Subject: [PATCH] delete: reproduce rsync traversal order for extras removal Collect each directory's entries up front and process extraneous subdirectories first (descending name, depth-first), then extraneous files (descending name), then descend into kept subdirectories in ascending order. Emit a trailing slash for deleted directories in observers/dry-run output. This matches rsync's delete order for --delete-before/--delete-after/--delete-delay and for dry-run listings. --- src/shared/delete_plan.c | 155 +++++++----- src/shared/utils.c | 515 +++++++++++++++++++++++++++------------ src/shared/utils.h | 22 ++ 3 files changed, 485 insertions(+), 207 deletions(-) diff --git a/src/shared/delete_plan.c b/src/shared/delete_plan.c index 8aa96f7..644fb4c 100644 --- a/src/shared/delete_plan.c +++ b/src/shared/delete_plan.c @@ -471,6 +471,24 @@ static void notify_deleted(DeletePlanSession* session, const char* rel) { session->observer(session->observer_context, rel); } +/* A removed directory is reported with rsync's trailing slash (`deleting dir/`) + while files keep their bare path. */ +static void notify_deleted_dir(DeletePlanSession* session, const char* rel) { + if (!session || !session->observer || !rel) + return; + size_t len = strlen(rel); + char* with_slash = malloc(len + 2); + if (!with_slash) { + session->observer(session->observer_context, rel); + return; + } + memcpy(with_slash, rel, len); + with_slash[len] = '/'; + with_slash[len + 1] = '\0'; + session->observer(session->observer_context, with_slash); + free(with_slash); +} + DeletePlanSession* delete_plan_session_create(const Config* config) { if (!config) return NULL; @@ -696,7 +714,7 @@ static bool process_extra_dir(int dirfd, const char* name, const char* child_rel session->deleted++; session->planned++; log_deleted(child_rel); - notify_deleted(session, child_rel); + notify_deleted_dir(session, child_rel); *removed = true; return true; } @@ -733,81 +751,108 @@ static bool process_children(int dirfd, const char* dir_rel, const ArrayList* ke const ArrayList* keep_files, bool at_root, bool force_now, const PlanSkips* skips, DeletePlanSession* session, bool* survives) { *survives = false; - int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - if (scanfd < 0) + DeleteDirEntry* entries = NULL; + size_t count = 0; + bool collect_ok = true; + if (!delete_dir_entries_collect(dirfd, &entries, &count, &collect_ok)) return false; - DIR* dir = fdopendir(scanfd); - if (!dir) { - close(scanfd); + 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)); + bool* force = calloc(count ? count : 1, sizeof(bool)); + if (!shielded || !is_extra || !force) { + free(shielded); + free(is_extra); + free(force); + delete_dir_entries_free(entries, count); return false; } - bool operation_ok = true; - bool local_survives = false; - const struct dirent* entry; - while ((entry = readdir(dir)) != NULL) { - if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) - continue; + + /* rsync's order: extraneous subdirectories in descending name order, then + extraneous files in descending name order (kept entries survive and are not + touched here — a kept subdirectory gets its own per-directory plan). */ + 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 = - (strcmp(dir_rel, ".") == 0) ? str_dup(entry->d_name) : path_cat(dir_rel, entry->d_name); + (strcmp(dir_rel, ".") == 0) ? str_dup(entries[i].name) : path_cat(dir_rel, entries[i].name); if (!child_rel) { operation_ok = false; continue; } if (path_under_skip_prefix(child_rel, at_root, skips->entries, skips->count)) { + shielded[i] = true; local_survives = true; free(child_rel); continue; } - struct stat st; - if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) { - if (errno != ENOENT) - operation_ok = false; - free(child_rel); - continue; - } - bool is_dir = S_ISDIR(st.st_mode); - bool in_keep_dirs = is_dir && list_contains_str(keep_dirs, entry->d_name); - bool in_keep_files = !is_dir && list_contains_str(keep_files, entry->d_name); + bool is_dir = entries[i].is_dir; + bool in_keep_dirs = is_dir && list_contains_str(keep_dirs, entries[i].name); + bool in_keep_files = !is_dir && list_contains_str(keep_files, entries[i].name); bool rule_protected = skips->protect_rules && - filter_rules_apply_side(skips->protect_rules, child_rel, entry->d_name, is_dir, + filter_rules_apply_side(skips->protect_rules, child_rel, entries[i].name, is_dir, FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT; - if (in_keep_dirs) { + if (in_keep_dirs || in_keep_files || rule_protected) { + shielded[i] = true; local_survives = true; - } else if (keep_dirs && !is_dir && list_contains_str(keep_dirs, entry->d_name)) { - /* Destination file blocks a source directory: clear it now, whatever the - delete timing, so the directory can be created. */ - if (!process_extra_file(dirfd, entry->d_name, child_rel, true, session)) - operation_ok = false; - } else if (in_keep_files) { - local_survives = true; - } else if (keep_files && is_dir && list_contains_str(keep_files, entry->d_name)) { - /* Destination directory blocks a source file: remove it now. */ - bool removed = false; - if (!process_extra_dir(dirfd, entry->d_name, child_rel, true, skips, session, &removed)) - operation_ok = false; - else if (!removed) - local_survives = true; } else if (is_dir) { - if (rule_protected) { - local_survives = true; - } else { - bool removed = false; - if (!process_extra_dir(dirfd, entry->d_name, child_rel, force_now, skips, session, - &removed)) - operation_ok = false; - else if (!removed) - local_survives = true; - } - } else if (rule_protected) { - local_survives = true; + /* A destination directory blocks a source file of the same name: remove + it now, whatever the delete timing, so the file can be created. */ + is_extra[i] = true; + force[i] = keep_files && list_contains_str(keep_files, entries[i].name); } else { - if (!process_extra_file(dirfd, entry->d_name, child_rel, force_now, session)) - operation_ok = false; + /* A destination file blocks a source directory of the same name: clear it + now so the directory can be created. */ + is_extra[i] = true; + force[i] = keep_dirs && list_contains_str(keep_dirs, entries[i].name); } free(child_rel); } - closedir(dir); + + /* Pass 1: extraneous subdirectories, descending. */ + for (size_t i = 0; i < dir_count; i++) { + if (!is_extra[i]) + continue; + char* child_rel = + (strcmp(dir_rel, ".") == 0) ? str_dup(entries[i].name) : path_cat(dir_rel, entries[i].name); + if (!child_rel) { + operation_ok = false; + continue; + } + bool removed = false; + if (!process_extra_dir(dirfd, entries[i].name, child_rel, force[i] || force_now, skips, session, + &removed)) + operation_ok = false; + else if (!removed) + 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 = + (strcmp(dir_rel, ".") == 0) ? str_dup(entries[i].name) : path_cat(dir_rel, entries[i].name); + if (!child_rel) { + operation_ok = false; + continue; + } + if (!process_extra_file(dirfd, entries[i].name, child_rel, force[i] || force_now, session)) + operation_ok = false; + free(child_rel); + } + + free(shielded); + free(is_extra); + free(force); + delete_dir_entries_free(entries, count); *survives = local_survives; return operation_ok; } @@ -981,7 +1026,7 @@ static bool apply_deferred_path(DeletePlanSession* session, const Config* config session->deleted++; session->planned++; log_deleted(rel); - notify_deleted(session, rel); + notify_deleted_dir(session, rel); } else if (errno != ENOENT && errno != ENOTEMPTY && errno != EEXIST) { close(parent_fd); free(leaf); diff --git a/src/shared/utils.c b/src/shared/utils.c index 52bb5b2..d028e36 100644 --- a/src/shared/utils.c +++ b/src/shared/utils.c @@ -672,22 +672,24 @@ static bool is_synced_dir(const PathIndex* dirs, const char* rel) { return path_index_contains(dirs, rel[0] == '\0' ? "." : rel); } -/* 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). */ -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) { - /* openat(dirfd, ".") opens an independent file description: a dup() would - share dirfd's file offset and a prior pass could leave the stream drained. */ +/* 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; @@ -696,17 +698,127 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* k close(scanfd); return false; } - bool operation_ok = true; - bool local_survives = 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); + 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; - char* child_rel = path_cat((char*)rel_path, entry->d_name); + 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; @@ -718,93 +830,142 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, const PathIndex* k 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, rel_path[0] == '\0', skips, skip_count)) { + if (path_under_skip_prefix(child_rel, at_root, skips, skip_count)) { + shielded[i] = true; local_survives = true; - free(child_rel); - continue; - } - struct stat st; - if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) { - if (errno != ENOENT) - operation_ok = false; - free(child_rel); - continue; - } - bool is_dir = S_ISDIR(st.st_mode); - if (protect_rules && filter_rules_apply_side(protect_rules, child_rel, entry->d_name, is_dir, - FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT) { + } 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; - free(child_rel); + } 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; } - if (is_dir) { - int childfd = openat(dirfd, entry->d_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) { + 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; - } - bool child_synced = dirs && path_index_contains(dirs, child_rel); - if (child_synced || keep_is_dir(keep, child_rel)) { - /* A synchronized directory and a directory holding kept content are - never removed. */ - local_survives = true; - } else if (child_all_removed && deletable) { - if (budget->deleted >= budget->max_delete) { - budget->limit_hit = true; - budget->skipped++; - local_survives = true; - } else if (unlinkat(dirfd, entry->d_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++; - if (observer) - observer(observer_context, child_rel); - } - } else { - local_survives = true; - } - } else { - bool found = keep_is_file(keep, child_rel); - if (found || !deletable) { - /* Kept file, or a child of a directory that is not synchronized: never - an extra for this run. */ - local_survives = true; - } else if (budget->deleted >= budget->max_delete) { + 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, entry->d_name, 0) != 0) { - if (errno != ENOENT) + } 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); } - closedir(dir); + + free(shielded); + free(is_extra); + delete_dir_entries_free(entries, count); *all_removed = !local_survives; return operation_ok; } @@ -817,97 +978,147 @@ static bool list_extras_fd(int dirfd, const char* rel_path, const PathIndex* kee const DeleteSkipEntry* skips, int skip_count, const FilterRuleList* protect_rules, bool parent_deletable, bool* all_removed) { - int scanfd = openat(dirfd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); - if (scanfd < 0) + DeleteDirEntry* entries = NULL; + size_t count = 0; + bool collect_ok = true; + if (!delete_dir_entries_collect(dirfd, &entries, &count, &collect_ok)) return false; - DIR* dir = fdopendir(scanfd); - if (!dir) { - close(scanfd); + 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 operation_ok = true; - bool local_survives = false; bool deletable = parent_deletable || is_synced_dir(dirs, rel_path); - const struct dirent* entry; - while ((entry = readdir(dir)) != NULL) { - if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) - continue; - char* child_rel = path_cat((char*)rel_path, entry->d_name); + 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, rel_path[0] == '\0', skips, skip_count)) { + if (path_under_skip_prefix(child_rel, at_root, skips, skip_count)) { + shielded[i] = true; local_survives = true; - free(child_rel); - continue; - } - struct stat st; - if (fstatat(dirfd, entry->d_name, &st, AT_SYMLINK_NOFOLLOW) != 0) { - if (errno != ENOENT) - operation_ok = false; - free(child_rel); - continue; - } - bool is_dir = S_ISDIR(st.st_mode); - if (protect_rules && filter_rules_apply_side(protect_rules, child_rel, entry->d_name, is_dir, - FILTER_SIDE_RECEIVER) == FILTER_ACTION_PROTECT) { + } 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; - free(child_rel); + } 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; } - if (is_dir) { - int childfd = openat(dirfd, entry->d_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) { + 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; - } - bool child_synced = dirs && path_index_contains(dirs, child_rel); - if (child_synced || keep_is_dir(keep, child_rel)) { - local_survives = true; - } else 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; - } - } else { - bool found = keep_is_file(keep, child_rel); - if (found || !deletable) { - local_survives = true; - } else { - char* copy = str_dup(child_rel); - if (!copy || !array_list_add(out, copy)) { + 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); } - closedir(dir); + + /* 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; } diff --git a/src/shared/utils.h b/src/shared/utils.h index 5d746f6..7bf89d8 100644 --- a/src/shared/utils.h +++ b/src/shared/utils.h @@ -134,6 +134,28 @@ typedef struct { 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