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
+5 -67
View File
@@ -412,6 +412,10 @@ static bool prepare_scanner(const Config* config, int num_threads, PreparedScann
options->exclude_per_dir_filter_files = config->per_dir_filter_count >= 2;
options->dirs = config->dirs;
options->relative = config->relative;
/* --no-implied-dirs only has meaning with -R (rsync): without it the option
is a documented no-op, so the scanner must not suppress directory
metadata. */
options->no_implied_dirs = config->no_implied_dirs && config->relative;
/* -R/--relative outside --files-from reconstructs every destination path from
* the source spec (rsync's '/./' cut point). With --files-from the listed
* entry already supplies the bare relative path, so no prefix is built. */
@@ -572,72 +576,6 @@ static const char* delete_plan_walk_root(const Config* config, const ArrayList*
return marker;
}
/* True when some --files-from entry is an ancestor-or-equal directory of
* `rel` (an empty entry -- the whole tree "." -- counts as the root). */
static bool file_list_ancestor_listed(const FileListSet* set, const char* rel) {
if (!set)
return true;
for (int i = 0; i < set->count; i++) {
const char* listed = set->entries[i];
if (listed[0] == '\0')
return true;
size_t n = strlen(listed);
if (strncmp(rel, listed, n) == 0 && (rel[n] == '/' || rel[n] == '\0'))
return true;
}
return false;
}
/* --no-implied-dirs (meaningful only with -R + --files-from): a listed file
* may only be placed when its parent directory (or one of its ancestors) is
* itself an explicitly listed entry. rsync omits a file whose implied parent
* directory is suppressed, and an explicitly listed file that cannot be placed
* fails the transfer; FastSync fails the whole run up front with a clear error
* (it has no per-entry skip channel). Without -R or --files-from the option
* has no effect. */
static bool no_implied_dirs_files_from_valid(const Config* config) {
if (!config->no_implied_dirs || !config->relative)
return true;
const FileListSet* set = (const FileListSet*)config->files_from_set;
if (!set)
return true;
for (int i = 0; i < set->count; i++) {
const char* entry = set->entries[i];
if (entry[0] == '\0')
continue;
char* full = path_cat(config->send_directory, entry);
if (!full)
return false;
struct stat st;
bool is_file = lstat(full, &st) == 0 && S_ISREG(st.st_mode);
free(full);
if (!is_file)
continue;
const char* slash = strrchr(entry, '/');
if (!slash)
continue; /* top-level file: its parent is the receive root */
size_t parent_len = (size_t)(slash - entry);
if (parent_len == 0)
continue;
char* parent = malloc(parent_len + 1);
if (!parent)
return false;
memcpy(parent, entry, parent_len);
parent[parent_len] = '\0';
bool listed = file_list_ancestor_listed(set, parent);
if (!listed) {
log_message(LOG_LEVEL_ERROR,
"--no-implied-dirs: cannot place file '%s': parent directory '%s' is not "
"explicitly listed (list the directory or drop --no-implied-dirs)",
entry, parent);
}
free(parent);
if (!listed)
return false;
}
return true;
}
/* The destination-relative mirror path for a missing --files-from entry: where
a PRESENT entry with the same name would have been written. With -R that is
the entry's bare relative path (the bare wire path the receiver uses);
@@ -751,7 +689,7 @@ static bool files_from_list_check(const Config* config, ArrayList* missing_dest,
"--ignore-missing-args: ignored %d missing --files-from entr%s", *skipped_out,
*skipped_out == 1 ? "y" : "ies");
}
return no_implied_dirs_files_from_valid(config);
return true;
}
/* Basis directories are honored by the receiver's per-file incremental check,