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
+69
View File
@@ -4797,3 +4797,72 @@ class TestDirectoryAndSymlinkTimes:
"-J must suppress symlink times"
assert abs(os.stat(os.path.join(recv_j, "sub")).st_mtime - DISTINCT_MTIME) < 2, \
"-J must not suppress directory times"
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
def test_preserve_does_not_create_empty_source_dir(self, shared_server, mt):
"""P7 Wave D #1: a captured-but-EMPTY source directory is never created
at the destination. The scanner records its time (it is transmitted via
STATUS_DIR_TIMES), but the receiver treats that entry as record-only, so
`-a` keeps the documented "empty dirs are never transferred" behavior."""
source = os.path.join(TEST_DATA_DIR, f"empty_dir_{'m' if mt else 's'}_src")
dest = os.path.join(TEST_DATA_DIR, f"empty_dir_{'m' if mt else 's'}_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "keep.txt"), "wb") as fh:
fh.write(b"regular file\n")
os.makedirs(os.path.join(source, "empty_sub"))
flags = ["-a"] + (["--threads"] if mt else [])
received = self._run(source, dest, flags, shared_server)
assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing"
assert not os.path.lexists(os.path.join(received, "empty_sub")), \
f"-a created an empty source directory at {received}/empty_sub"
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
def test_prune_empty_dirs_still_does_not_create_empty_dir(self, shared_server, mt):
"""P7 Wave D #1: `-a -m` (--prune-empty-dirs) keeps its semantics -- a
captured empty directory is never created even though its time is
recorded."""
source = os.path.join(TEST_DATA_DIR, f"prune_empty_{'m' if mt else 's'}_src")
dest = os.path.join(TEST_DATA_DIR, f"prune_empty_{'m' if mt else 's'}_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "keep.txt"), "wb") as fh:
fh.write(b"regular file\n")
os.makedirs(os.path.join(source, "empty_sub"))
flags = ["-a", "-m"] + (["--threads"] if mt else [])
received = self._run(source, dest, flags, shared_server)
assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing"
assert not os.path.lexists(os.path.join(received, "empty_sub")), \
f"-a -m created an empty source directory at {received}/empty_sub"
@pytest.mark.ci
@pytest.mark.parametrize("mt", [False, True])
def test_collision_at_dir_time_path_does_not_abort(self, shared_server, mt):
"""P7 Wave D #1: a pre-existing regular file at a source-empty-dir's
mirror path must not abort the transfer (the old mkdir failed and failed
the run) and must not be clobbered."""
source = os.path.join(TEST_DATA_DIR, f"dirtime_collide_{'m' if mt else 's'}_src")
dest = os.path.join(TEST_DATA_DIR, f"dirtime_collide_{'m' if mt else 's'}_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "keep.txt"), "wb") as fh:
fh.write(b"regular file\n")
os.makedirs(os.path.join(source, "collide"))
# Plant a regular file at exactly the mirror path of source/collide.
received = get_dest_received_dir(dest, source)
os.makedirs(received, exist_ok=True)
blocker = os.path.join(received, "collide")
with open(blocker, "wb") as fh:
fh.write(b"pre-existing blocker\n")
flags = ["-a"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, \
f"-a aborted on a pre-existing file at an empty-dir path: " \
f"{(result.stderr or result.stdout)[:400]}"
assert os.path.isfile(blocker) and not os.path.islink(blocker), \
"the pre-existing blocker was replaced by a directory"
with open(blocker, "rb") as fh:
assert fh.read() == b"pre-existing blocker\n", "the blocker file was clobbered"
assert os.path.isfile(os.path.join(received, "keep.txt")), "regular file missing"
+9 -3
View File
@@ -303,9 +303,11 @@ static void test_chmod_changes() {
}
/* P7 Wave D: symlink metadata is applied with no-follow primitives, and -J
* (omit_link_times) suppresses the timestamp. The test is robust to
* filesystems that silently ignore symlink timestamps: it mainly proves the
* omit path never touches the stored time. */
* (omit_link_times) suppresses the timestamp. The positive apply path is
* asserted when the filesystem actually stores symlink timestamps; a filesystem
* that silently ignores them (or a platform where utimensat AT_SYMLINK_NOFOLLOW
* is unsupported) is tolerated, in which case only the omit-path invariant is
* checked. */
static void test_file_restore_symlink_metadata() {
const char* dir = "temp_symlink_md_test";
const char* target = "temp_symlink_md_test/target";
@@ -317,11 +319,13 @@ static void test_file_restore_symlink_metadata() {
fclose(f);
EXPECT_EQ_INT(symlink("target", link), 0);
/* Positive path: a non-omitted apply stamps the link's own mtime. */
FileMetadata applied = {.mtime_sec = 1000000000, .mtime_nsec = 0};
file_restore_symlink_metadata(link, &applied, false);
struct stat st;
EXPECT_EQ_INT(lstat(link, &st), 0);
EXPECT_TRUE(S_ISLNK(st.st_mode));
bool symlink_times_supported = ((int)st.st_mtime == 1000000000);
time_t t1 = st.st_mtime;
/* -J: a different time must be left untouched. */
@@ -329,6 +333,8 @@ static void test_file_restore_symlink_metadata() {
file_restore_symlink_metadata(link, &newer, true);
EXPECT_EQ_INT(lstat(link, &st), 0);
EXPECT_EQ_INT((int)st.st_mtime, (int)t1);
if (symlink_times_supported)
EXPECT_EQ_INT((int)st.st_mtime, 1000000000);
unlink(link);
unlink(target);
+10 -1
View File
@@ -1265,13 +1265,17 @@ static void test_files_from_relative_send_path() {
/* P7 Wave D: the recursive scan captures every traversed source directory as an
* is_dir File (metadata, no payload) in the shared dir_entries list, including
* the transfer root, so the sender can transmit directory times at the end. */
* the transfer root and an EMPTY directory. The empty dir is captured even
* though the receiver deliberately never creates it, so its time can still be
* applied when the destination already holds that directory. */
static void test_scanner_captures_directory_times() {
const char* root = "test_scan_dirtime";
const char* sub = "test_scan_dirtime/sub";
const char* empty = "test_scan_dirtime/empty";
const char* file1 = "test_scan_dirtime/sub/a.txt";
EXPECT_EQ_INT(mkdir(root, 0755), 0);
EXPECT_EQ_INT(mkdir(sub, 0755), 0);
EXPECT_EQ_INT(mkdir(empty, 0755), 0);
create_test_file(file1, "x");
ArrayList* dirs = array_list_create(file_destroy);
@@ -1289,6 +1293,7 @@ static void test_scanner_captures_directory_times() {
int found_root = 0;
int found_sub = 0;
int found_empty = 0;
for (int i = 0; i < dirs->size; i++) {
const File* file = (const File*)dirs->items[i];
EXPECT_TRUE(file->is_dir);
@@ -1297,13 +1302,17 @@ static void test_scanner_captures_directory_times() {
found_root = 1;
if (strcmp(file->path, sub) == 0)
found_sub = 1;
if (strcmp(file->path, empty) == 0)
found_empty = 1;
}
EXPECT_TRUE(found_root);
EXPECT_TRUE(found_sub);
EXPECT_TRUE(found_empty);
directory_scanner_destroy(scanner);
array_list_delete(dirs);
unlink(file1);
rmdir(empty);
rmdir(sub);
rmdir(root);
}