feat: all-or-nothing bounded delete walker

delete_extras_limited now rehearses a finite-capped deletion before unlinking
anything (an identical fd-relative walk that counts files and directories) and
returns DELETE_WALK_LIMIT_EXCEEDED with nothing removed when the run would
exceed the cap, so --max-delete is enforced per run instead of truncating the
deletion.  A directory that still holds entries the walker leaves in place
(protected excluded prefix, manifest-kept file, symlink) is left behind rather
than failing the whole deletion, matching rsync's leave-non-empty-dirs
behavior.  Rehearsal/delete each open an independent file description so a
prior pass cannot drain the directory stream.
This commit is contained in:
2026-09-06 21:49:57 +02:00
parent 3631df0a3e
commit 1c9b660ac0
2 changed files with 165 additions and 14 deletions
+23 -6
View File
@@ -9,22 +9,39 @@ char* str_dup(const char* string);
char* output_escape(const char* string, bool eight_bit_output);
char* path_cat(const char* path1, const char* path2);
bool glob_match(const char* pattern, const char* str);
bool delete_extras(const char* dest_root, ArrayList* manifest);
/* Result of a bounded extra-file deletion run. */
typedef enum {
/* Every extra entry was removed (or there were none). */
DELETE_WALK_OK = 0,
/* The destination holds more extras than the numeric cap for this run. With
the all-or-nothing max-delete semantics NOTHING was removed (the walker
counts first and refuses to start when the run would exceed the limit). */
DELETE_WALK_LIMIT_EXCEEDED,
/* 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, which the transfer links from and are never destination
content). */
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;
/* Remove files/dirs under dest_root that are not listed in manifest without
ever descending into a protected prefix (see DeleteSkipEntry). */
bool delete_extras_limited(const char* dest_root, ArrayList* manifest, size_t max_delete,
const DeleteSkipEntry* skips, int skip_count);
ever descending into a protected prefix (see DeleteSkipEntry). When
max_delete is not SIZE_MAX the run is all-or-nothing: extras are counted
first and DELETE_WALK_LIMIT_EXCEEDED is returned (with nothing removed) when
the count would exceed the cap. `deleted_out` optionally receives the number
of entries actually removed. */
DeleteWalkResult delete_extras_limited(const char* dest_root, ArrayList* manifest,
size_t max_delete, const DeleteSkipEntry* skips,
int skip_count, size_t* deleted_out);
bool delete_extras(const char* dest_root, ArrayList* manifest);
bool utils_set_authorized_root(int fd, const char* canonical_path);
/* The fd-only compatibility form is fail-closed for path-based operations;
* callers should use utils_set_authorized_root with the canonical identity. */