fix(p7-times): dir-time entries only record (never create dirs); bound/chunk dir-time frames; harden list add; docs+tests

Review fixes for Phase 7 Wave D.

#1 (HIGH): STATUS_DIR_TIMES entries no longer create directories. A new
receiver-only File.dir_time_only flag marks dir-time entries; file_save_to_disk_full
short-circuits them as FILE_SAVE_SKIPPED before any device/dir branch, so the sink
still accumulates metadata into the deferred DirTimeList but creates nothing. Empty
source dirs stay untransferred (-a), -m/--prune-empty-dirs semantics are preserved,
and a pre-existing regular file/symlink at an empty-dir mirror path no longer aborts
the transfer. dir_time_list_apply fstatat()s the leaf (AT_SYMLINK_NOFOLLOW) and skips
absent/non-directory paths QUIETLY; only a real existing directory is stamped.
Also initialize File.dir_time_only in file_create() (uninitialised garbage otherwise).

#2 (MED): send_dir_times() chunks entries into repeated STATUS_DIR_TIMES frames of at
most MAX_MANIFEST_ENTRIES, matching the receiver's per-frame bound; the tautological
> INT_MAX check is gone.

#3 (LOW): dir_time_list_add() assigns each grown array right after its realloc (no
dangling) and advances capacity only after both succeed.

#4 (LOW): RSYNC_COMPAT.md -- STATUS_MKDIR carries metadata, dir times are transmitted
via STATUS_DIR_TIMES and applied at the end, empty dirs are still never created; -m
rationale, -O row and Wave D notes updated. Summary counts untouched.

#5 (LOW): integration tests for the three #1 scenarios (empty-dir non-creation under
-a and -a -m, collision non-abort), scanner test now covers empty-dir capture, and
test_file_restore_symlink_metadata asserts the positive apply path when supported.

PROTOCOL_VERSION stays 2.17.0; config-frame layout unchanged.
This commit is contained in:
2026-09-12 11:06:05 +02:00
parent f1a447bb4a
commit 9749c7878c
14 changed files with 189 additions and 49 deletions
+41 -8
View File
@@ -551,6 +551,18 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
return FILE_SAVE_ERROR;
}
/* P7 Wave D #1: a STATUS_DIR_TIMES entry is RECORD-ONLY. The scanner
captures every traversed directory -- including empty ones whose parents
were never created by a child write and directories pruned by
-m/--prune-empty-dirs. Creating them here would resurrect empty
directories (an -a behavior change) and could abort the whole transfer on a
pre-existing regular file/symlink at the mirror path. Short-circuit before
any device/write-devices/directory branch and report it as skipped so the
sink still accumulates its metadata for the deferred DirTimeList
application, but create nothing. */
if (file->dir_time_only)
return FILE_SAVE_SKIPPED;
/* Device/special node (--devices/--specials): recreate the node instead of
writing content (privilege-gated, confined, rdev-validated). */
if (file->is_special)
@@ -2174,6 +2186,12 @@ bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetad
size_t new_capacity = list->capacity == 0 ? 16 : list->capacity * 2;
if (new_capacity < list->capacity)
return false;
/* Assign each grown array as soon as its realloc succeeds: the old block is
already freed by then, so discarding the pointer would dangle. capacity
is advanced only after BOTH reallocs succeed, so a partial failure leaves
capacity no larger than the entries allocation (the paths array may be
over-allocated, which is harmless) -- never a mismatched list the next
add could write past. */
char** grown_paths = realloc(list->paths, new_capacity * sizeof(char*));
if (!grown_paths)
return false;
@@ -2202,14 +2220,26 @@ void dir_time_list_apply(const DirTimeList* list, const char* root_directory) {
continue;
char* leaf = NULL;
/* The parent walk is fd-relative and O_NOFOLLOW, so a symlink planted in a
parent component can never redirect the utimensat outside the root. The
final component is a directory; AT_SYMLINK_NOFOLLOW additionally refuses
to follow a same-named symlink (a --keep-dirlinks style path). */
parent component can never redirect the utimensat outside the root. */
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
if (parent_fd < 0) {
free(dir_path);
continue;
}
/* A dir-time entry only records metadata: the directory is (deliberately)
not created from it, so an empty source directory (or one pruned by
-m/--prune-empty-dirs) may well not exist here. Skip absent paths
QUIETLY rather than warning for every one, and apply the times only to a
real directory that does exist. AT_SYMLINK_NOFOLLOW keeps a same-named
symlink from being followed; a pre-existing regular file/symlink is not a
directory, so it is left completely untouched. */
struct stat st;
if (fstatat(parent_fd, leaf, &st, AT_SYMLINK_NOFOLLOW) != 0 || !S_ISDIR(st.st_mode)) {
close(parent_fd);
free(leaf);
free(dir_path);
continue;
}
struct timespec times[2] = {
{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = list->entries[i].mtime_sec, .tv_nsec = list->entries[i].mtime_nsec}};
@@ -2265,11 +2295,13 @@ File* file_receive_directory(int file_descriptor, const Config* config) {
return file;
}
/* Receive one directory-time entry from the terminal STATUS_DIR_TIMES frame:
* the destination-relative wire path and (when metadata is negotiated) the
* directory's metadata frame. The created File is an is_dir entry routed
* through the regular store_file sink, exactly like a STATUS_MKDIR entry, so
* the same deferred DirTimeList application covers both. */
/* Receive one directory-time entry from a STATUS_DIR_TIMES frame: the
* destination-relative wire path and (when metadata is negotiated) the
* directory's metadata frame. The created File is an is_dir, dir_time_only
* entry routed through the regular store_file sink: the sink records its
* metadata into the deferred DirTimeList but never creates the directory (the
* scanner captures every traversed directory, including empty ones). Unlike a
* STATUS_MKDIR entry, this one must not create anything. */
File* file_receive_dir_time(int file_descriptor, const Config* config) {
char* path = receive_wire_str(file_descriptor);
if (path == NULL)
@@ -2287,6 +2319,7 @@ File* file_receive_dir_time(int file_descriptor, const Config* config) {
if (!file)
return NULL;
file->is_dir = true;
file->dir_time_only = true;
if (config && config->use_metadata) {
int meta_ok = 1;
file->metadata = metadata_receive(file_descriptor, &meta_ok);