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:
@@ -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,
|
||||
|
||||
+65
-3
@@ -830,7 +830,8 @@ static bool scanner_capture_dir_time(ArrayList* dir_entries, mtx_t* mutex, const
|
||||
const char* fs_path, bool relative_mode,
|
||||
const char* relative_prefix, bool preserve_atimes,
|
||||
bool preserve_crtimes, bool preserve_xattrs,
|
||||
bool preserve_acls) {
|
||||
bool preserve_acls, bool no_implied_dirs,
|
||||
const FileListSet* file_list) {
|
||||
if (!dir_entries || !root_path || !fs_path)
|
||||
return true;
|
||||
struct stat st;
|
||||
@@ -839,6 +840,13 @@ static bool scanner_capture_dir_time(ArrayList* dir_entries, mtx_t* mutex, const
|
||||
char* rel = scanner_path_relative(root_path, fs_path);
|
||||
if (!rel)
|
||||
return true;
|
||||
/* --no-implied-dirs: an implied parent directory (not listed, and not under
|
||||
a listed directory) keeps the destination's own/default attributes, so its
|
||||
source metadata is not transmitted. */
|
||||
if (no_implied_dirs && file_list && !file_list_dir_in_scope(file_list, rel)) {
|
||||
free(rel);
|
||||
return true;
|
||||
}
|
||||
if (relative_mode && rel[0] == '\0') {
|
||||
/* -R + --files-from: the transfer root itself has no bare relative wire
|
||||
path (matches the -R scan, which never emits the root). */
|
||||
@@ -902,6 +910,43 @@ static bool scanner_capture_dir_time(ArrayList* dir_entries, mtx_t* mutex, const
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Recursive scan: emit a payload-less directory entry for the directory that
|
||||
* just finished scanning. rsync creates every source directory at the
|
||||
* destination; FastSync otherwise creates one only implicitly through a
|
||||
* transferred child, so a directory emptied on the transfer side (physically
|
||||
* empty, or all of its entries filtered out) would never appear. The transfer
|
||||
* root is skipped (it maps to the receive root, which already exists), as are
|
||||
* --files-from (only listed items and their implied parents transfer),
|
||||
* --list-only (directory lines are emitted by the caller) and
|
||||
* -m/--prune-empty-dirs. Returns false on allocation failure. */
|
||||
static bool scanner_emit_empty_dir(DirectoryScanner* scanner, ArrayList* chunk_data) {
|
||||
if (!scanner->current_path || !scanner->current_rel || scanner->current_rel[0] == '\0')
|
||||
return true;
|
||||
struct stat st;
|
||||
if (stat(scanner->current_path, &st) != 0 || !S_ISDIR(st.st_mode))
|
||||
return true;
|
||||
File* dir = scanner_build_dir_file(scanner->current_path, &st, &scanner->options);
|
||||
if (!dir)
|
||||
return false;
|
||||
if (scanner->relative_mode) {
|
||||
dir->send_path = str_dup(scanner->current_rel);
|
||||
} else if (scanner->options.relative_prefix) {
|
||||
dir->send_path =
|
||||
scanner_prefix_send_path(scanner->options.relative_prefix, scanner->current_rel);
|
||||
}
|
||||
if ((scanner->relative_mode || scanner->options.relative_prefix) && !dir->send_path) {
|
||||
file_destroy(dir);
|
||||
return false;
|
||||
}
|
||||
if (scanner->options.preserve_xattrs || scanner->options.preserve_acls)
|
||||
dir->xattrs = xattr_capture_path(scanner->current_path, scanner->options.preserve_acls);
|
||||
if (!array_list_add(chunk_data, dir)) {
|
||||
file_destroy(dir);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Open the next queued directory and set up its filter context. Returns 1 when
|
||||
a directory is open, 0 when the queue is exhausted, and -1 on a fatal error.
|
||||
A directory that cannot be opened is an I/O error: it is recorded on the
|
||||
@@ -925,6 +970,7 @@ static int open_next_directory(DirectoryScanner* scanner) {
|
||||
* the directory that enqueued them. */
|
||||
const FilterNode* inherited = scanner->at_seed_dir ? scanner->seed_node : de->context;
|
||||
scanner->at_seed_dir = false;
|
||||
scanner->current_dir_produced = false;
|
||||
free(de);
|
||||
|
||||
free(scanner->current_rel);
|
||||
@@ -985,7 +1031,8 @@ static int open_next_directory(DirectoryScanner* scanner) {
|
||||
scanner->options.dir_entries, scanner->options.dir_entries_mutex, scanner->root_path,
|
||||
scanner->current_path, scanner->relative_mode, scanner->options.relative_prefix,
|
||||
scanner->options.preserve_atimes, scanner->options.preserve_crtimes,
|
||||
scanner->options.preserve_xattrs, scanner->options.preserve_acls)) {
|
||||
scanner->options.preserve_xattrs, scanner->options.preserve_acls,
|
||||
scanner->options.no_implied_dirs, scanner->options.file_list)) {
|
||||
closedir(scanner->current_dir);
|
||||
scanner->current_dir = NULL;
|
||||
free(scanner->current_path);
|
||||
@@ -1350,10 +1397,21 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
|
||||
const struct dirent* entry = readdir(scanner->current_dir);
|
||||
if (entry == NULL) {
|
||||
/* The directory is exhausted: if nothing was transferred or descended
|
||||
from it, recreate it at the destination as an explicit entry. */
|
||||
if (!scanner->current_dir_produced && !scanner->options.prune_empty_dirs &&
|
||||
!scanner->options.list_dirs && scanner->options.file_list == NULL) {
|
||||
if (!scanner_emit_empty_dir(scanner, chunk_data))
|
||||
scanner->failed = true;
|
||||
}
|
||||
closedir(scanner->current_dir);
|
||||
scanner->current_dir = NULL;
|
||||
free(scanner->current_path);
|
||||
scanner->current_path = NULL;
|
||||
if (scanner->failed) {
|
||||
array_list_delete(chunk_data);
|
||||
return NULL;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1488,6 +1546,7 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
scanner->failed = true;
|
||||
break;
|
||||
}
|
||||
scanner->current_dir_produced = true;
|
||||
free(cur_path);
|
||||
continue;
|
||||
}
|
||||
@@ -1502,6 +1561,7 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
scanner->current_dir_produced = true;
|
||||
int next_depth = scanner->current_depth + 1;
|
||||
if (scanner->options.max_depth <= 0 || next_depth < scanner->options.max_depth) {
|
||||
DirEntry* de = dir_entry_create(cur_path, next_depth, scanner->current_node);
|
||||
@@ -1577,6 +1637,7 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
|
||||
scanner->failed = true;
|
||||
break;
|
||||
}
|
||||
scanner->current_dir_produced = true;
|
||||
chunk_data_size += file->data->size;
|
||||
if (chunk_data_size > scanner->options.chunk_size) {
|
||||
free(rel_copy);
|
||||
@@ -2206,7 +2267,8 @@ ParallelScanner* parallel_scanner_create_with_options(const char* root_directory
|
||||
root_directory, options->relative && options->file_list != NULL,
|
||||
options->relative_prefix, options->preserve_atimes,
|
||||
options->preserve_crtimes, options->preserve_xattrs,
|
||||
options->preserve_acls)) {
|
||||
options->preserve_acls, options->no_implied_dirs,
|
||||
options->file_list)) {
|
||||
array_list_delete(root_files);
|
||||
array_list_delete(subdirs);
|
||||
parallel_scanner_destroy(ps);
|
||||
|
||||
@@ -151,6 +151,11 @@ typedef struct {
|
||||
bool capture_dir_times;
|
||||
ArrayList* dir_entries;
|
||||
mtx_t* dir_entries_mutex;
|
||||
/* --no-implied-dirs with -R + --files-from: a directory that is only an
|
||||
* implied parent of a listed entry (not itself listed, nor below a listed
|
||||
* directory) must not carry source metadata; it is created with default
|
||||
* attributes at the destination, matching rsync. */
|
||||
bool no_implied_dirs;
|
||||
} ScannerOptions;
|
||||
|
||||
/* Internal per-scanner filter state. FilterNode chains represent the ordered
|
||||
@@ -168,6 +173,11 @@ typedef struct {
|
||||
int current_depth;
|
||||
dev_t root_dev;
|
||||
bool failed;
|
||||
/* Recursive scan: whether the open directory yielded any transferred or
|
||||
descended entry. When it did not, closing it emits a directory entry so
|
||||
the empty source directory is recreated at the destination (rsync
|
||||
parity). */
|
||||
bool current_dir_produced;
|
||||
/* Phase 2 (files-from / filter layer). */
|
||||
char* root_path; /* transfer root (fs path) for rel computation */
|
||||
char* current_rel; /* rel path of the open directory ("" == root) */
|
||||
|
||||
+3
-2
@@ -103,8 +103,9 @@ void print_usage(void) {
|
||||
printf(" -R, --relative With --files-from, preserve each listed entry's relative path\n");
|
||||
printf(" below the destination root instead of mirroring the full\n");
|
||||
printf(" source path (no effect without --files-from)\n");
|
||||
printf(" --no-implied-dirs With -R --files-from, refuse to place a listed file whose\n");
|
||||
printf(" parent directory is not itself listed\n");
|
||||
printf(" --no-implied-dirs With -R, do not apply the source metadata of a listed file's\n");
|
||||
printf(" implied parent directories (they are still created with\n");
|
||||
printf(" default attributes)\n");
|
||||
printf(" --mkpath Create the destination root directory on the server when it\n");
|
||||
printf(" does not exist yet\n");
|
||||
printf(" --exclude <pattern>, --exclude=<pattern> Exclude files matching pattern\n");
|
||||
|
||||
+4
-2
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user