delete: preserve empty dirs in --backup-dir when deleting#967
Conversation
|
@x15sr71 thanks for the PR! There is a subtle bug with it in the handling of files that should not go into the backup dir. I've added another test to the PR that demonstrates the bug |
|
Thanks for the catch! Switched to |
|
Pushed a fix for the Solaris CI failure in the last CI run - Solaris returns |
|
@tridge The NetBSD failure is in daemon-access, the wo module was missing from the daemon listing, which is unrelated to this PR's delete/backup path. Both |
|
@tridge, pinging in case this got buried — the run results are no longer visible on the PR checks tab, so linking the only failed run from the last workflow run for reference: NetBSD run #394 Could you approve a workflow re-run when you get a chance? Thanks! P.S. Happy to rebase and squash the merge commits if you'd like — the history would just be your test commit and mine. |
| ok = do_rmdir_at(fbuf) == 0; | ||
| if (ok && make_backups > 0 && !(flags & DEL_FOR_BACKUP) && backup_dir) { | ||
| char *buf = get_backup_name(fbuf); | ||
| if (buf && do_mkdir_at(buf, ACCESSPERMS) == 0) { | ||
| if (INFO_GTE(BACKUP, 1)) |
There was a problem hiding this comment.
This removes fbuf before backup creation is known to succeed. If get_backup_name() returns NULL or do_mkdir_at() fails, the directory is gone but ok remains true and deletion reports success. ACCESSPERMS also loses the original directory mode. Please handle backup failure without reporting success and preserve the original attributes with failure and mode-preservation tests.
| # PR #967 makes delete_item() rename a directory into --backup-dir before | ||
| # falling back to rmdir, so empty dirs are preserved in the backup tree |
There was a problem hiding this comment.
This still describes the superseded rename implementation. Please update it to describe the protected-directory regression the final test guards.
this demonstrates a subtle bug in PR 967
3cfc8f4 to
d8e6057
Compare
tridge
left a comment
There was a problem hiding this comment.
Automated review — generated by OpenAI Codex. Findings were reproduced
locally but not exhaustively hand-verified.
The rmdir-first direction is sound (the successful rmdir() is the atomic
emptiness proof, so a non-empty or protected-survivor directory is left intact),
and the Solaris EEXIST handling is correct (POSIX permits EEXIST or
ENOTEMPTY for a non-empty rmdir()/unlinkat(AT_REMOVEDIR); the wrapper's
parent-open path has no other normal source of EEXIST). Nested a/b/c works —
deletion is deepest-first, so when c is removed the destination parents still
exist and get_backup_name()/copy_valid_path() create backup/a/b; no ENOENT
gap. The make_backups > 0 / DEL_FOR_BACKUP / backup_dir guards are correct,
and --delete-delay/--delete-after/--force all reach the same path.
Three issues should be fixed before this is release-ready; the first two are
data-loss paths.
1. Backup failure after rmdir() is treated as success
In delete_item()'s S_ISDIR branch, a successful rmdir() sets ok, but a
subsequent failure of get_backup_name() or do_mkdir_at() never clears it.
Reproduced:
- Unwritable
--backup-dir: the empty directory is removed, no backup is
created, a warning is printed, and rsync exits 0. - Blocked intermediate backup component:
get_backup_name()returnsNULL
after the directory was already removed; rsync exits 23 but the directory is
already gone.
File contents cannot be lost (the directory was proven empty), but the directory
object and its metadata are lost despite --backup. This differs from the
non-directory path, where a make_backup() failure aborts the deletion. Suggest
creating the backup directory before the rmdir() (so a failure leaves the
source in place), or otherwise propagating the failure instead of deleting.
2. EEXIST from the backup mkdir() accepts a non-directory
The EEXIST/EISDIR tolerance does not check the existing object's type.
Reproduced: with a regular file already at backup-dir/empty, dst/empty/ is
deleted, the stale regular file remains, no directory backup is created, and
rsync exits 0. Tolerating an existing directory is required (children may have
been backed up during the same recursive deletion), but a regular file or symlink
must not be accepted — it needs type validation and DEL_FOR_BACKUP collision
handling (or failure propagation).
3. Directory attributes are not preserved
The leaf is recreated with ACCESSPERMS only; an -aAX probe changed an empty
directory from mode 0701 to 0775 and lost its mtime, xattrs, and ownership.
Nested parents fare only slightly better: copy_valid_path() transfers their
attributes initially, but creating child backup directories dirties their mtimes,
and the EEXIST path does not restore them, so nested directory mtimes are lost.
Since copy_valid_path() explicitly attempts to transfer directory settings,
this reads as a defect rather than an intentional placeholder-directory policy.
Tests
Both new tests pass on HEAD and are non-vacuous (basic case, child-collision
preservation, nested dirs, suffix-mode regression, --delete-delay). Missing
coverage: an existing regular file/symlink at the backup leaf; a backup
mkdir()/get_backup_name() failure and its exit code; and mode/owner/mtime/
xattr/ACL preservation. backup-pinned-dir's comments still describe the earlier
rename-based implementation and should be updated.
|
I went through this one carefully — reproduced the bug, mutation-tested the new hunk against the tests that ship with it, and checked whether the existing backup machinery could do the job instead. Summary: the shape of the fix is right, but it is partial — the backed-up directory loses its mode and timestamps. The bug is real, and this code is neededConfirmed on master: with I also checked whether routing directories through the existing The gap: metadata is not preserved
For STRUCT_STAT dir_st;
int want_backup = make_backups > 0 && !(flags & DEL_FOR_BACKUP) && backup_dir;
int have_st = want_backup && do_lstat_at(fbuf, &dir_st) == 0;
what = "rmdir";
ok = do_rmdir_at(fbuf) == 0;
if (ok && want_backup) {
char *buf = get_backup_name(fbuf);
mode_t bmode = have_st ? dir_st.st_mode & CHMOD_BITS : ACCESSPERMS;
if (buf && do_mkdir_at(buf, bmode) == 0) {
if (have_st)
set_times(buf, &dir_st);
...I tried that locally: mode Mutation testing of the new hunkEleven small edits to the added code, each rebuilt and run against
One I want to explicitly not overclaim: dropping the Suggestions
|
Description
When using
--backup--backup-dir--delete, rsync was silently removing empty directories from the destination instead of preserving them in--backup-dir. Files in those directories were backed up correctly, but the empty directories themselves were not.Root cause
delete_item()routedS_ISDIRdirectly todo_rmdir_at(), bypassing themake_backups/backup_dirlogic that only existed in the non-directory else branch.Fix
After
delete_dir_contents()processes all children,delete_item()attempts rmdir()first — this is the atomic emptiness proof. If anything survived inside (e.g. a protect-filtered file),rmdir()fails and the directory is left untouched. Ifrmdir()succeeds,mkdir()recreates the directory in--backup-dir.EEXISTfrommkdir()is expected when child file backups already created the target directory.Also fixes
rmdir()error handling on Solaris, which returnsEEXIST(errno 17) instead ofENOTEMPTYfor non-empty directories — both are POSIX-valid. The existing check now handles both.Fixes #842.
Tests added in
testsuite/backup-empty-dir_test.py:backup-dirbackup-dir/sub/from child filebackups is not destroyed when parent dir is later backed up
--backup-dir— empty dir still deleted--delete-delayvariant — backup path reached for queued deletions