fix(fs): recreate empty dirs, replace blocking file; -R --no-implied-dirs implied parents

- Recursive scans emit a directory entry for every traversed directory that
  produced no transferred child, so empty source dirs (and dirs emptied by
  filtering) are recreated like rsync; -m prunes them, --files-from/--list-only
  never emit implicit dirs.
- A directory entry now replaces a destination regular file (rsync removes the
  non-directory) instead of aborting; confined to the secure parent fd.
- -R --no-implied-dirs --files-from: stop refusing a listed file whose parent
  is not listed; create the implied parent with default attributes (no source
  metadata is captured for it), matching rsync 3.4.1.
- Differential gate: drop min_size/empty_dirs_recursive/dirs_plain allowlist
  entries (dirs_plain now hands FastSync the same trailing-slash source as
  rsync); add differential test for the files-from implied parent.
- Docs: -d row -> Parity, tally 108/24/24.

No wire change (PROTOCOL_VERSION stays 2.26.0).
This commit is contained in:
2026-09-18 20:25:42 +02:00
parent d162d93570
commit 13708352ec
12 changed files with 191 additions and 146 deletions
+4 -2
View File
@@ -424,8 +424,10 @@ typedef struct Config {
* repeated -F adds --filter='- .rsync-filter' so they are excluded too. */
int per_dir_filter_count;
bool one_file_system; /* -x/--one-file-system: do not cross filesystem boundaries */
/* --no-implied-dirs: client-only. With -R + --files-from, refuse to place a
* listed file whose ancestor directory is not itself explicitly listed. */
/* --no-implied-dirs: client-only. With -R, do not transfer the source
* metadata of the parent directories implied by a listed path; an unlisted
* implied parent is still created (with default attributes) so the listed
* file can be placed, matching rsync. */
bool no_implied_dirs;
/* -d/--dirs: client-only. Transfer the directory entries named by the
* source argument / --files-from list without recursing into contents. */
+20
View File
@@ -807,6 +807,26 @@ bool file_ensure_directory_secure(const char* path) {
} else if (errno == EEXIST) {
dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
}
} else if (dir_fd < 0 && (errno == ENOTDIR || errno == ELOOP)) {
/* rsync replaces a destination non-directory (regular file or symlink)
with an incoming directory. Confined to the already-opened secure
parent fd: the leaf is unlinked by name (never followed) and only a
non-directory is ever removed, so this cannot escape the authorized
root or remove a pre-existing directory tree. A symlink is left alone:
replacing it is not required for FastSync's transferred directories and
keeps --keep-dirlinks semantics untouched. */
struct stat leaf_st;
if (fstatat(parent_fd, leaf, &leaf_st, AT_SYMLINK_NOFOLLOW) == 0 &&
!S_ISDIR(leaf_st.st_mode) && !S_ISLNK(leaf_st.st_mode)) {
if (unlinkat(parent_fd, leaf, 0) == 0) {
if (mkdirat(parent_fd, leaf, (mode_t)(0777 & ~(mode_t)file_process_umask())) == 0) {
created = true;
} else if (errno != EEXIST) {
/* leave dir_fd < 0 so the caller sees the failure */
}
dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
}
}
}
bool ok = dir_fd >= 0;
/* --copy-as owns a directory this call just created (the final component;