From 2d841405e6689e69aae7755764aa3d2812c57e4b Mon Sep 17 00:00:00 2001 From: TapTap Date: Sun, 6 Sep 2026 12:49:26 +0200 Subject: [PATCH] test: cover -x cross-device skip without root; harden OneFileSystem tests Add a rootless unit test that reaches the actual st_dev skip branch in both the sequential and parallel (-m) scanners: a symlink nested under the scan root points at a directory on /dev/shm (a different device than the build fs) and, under --copy-links semantics, -x must drop that subtree while a plain scan includes it. Skips only when no cross-device target exists. Integration OneFileSystem test now cleans both dest dirs up front and reports a busy test mountpoint instead of ignoring the umount result. RSYNC_COMPAT.md notes that cross-filesystem mount-point subdirectories are dropped entirely (rsync parity). --- RSYNC_COMPAT.md | 2 +- tests/integration/test_features.py | 9 +- tests/test_scanner.c | 150 +++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index 1716055..fa7f4b6 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -64,7 +64,7 @@ This document maps rsync's full feature set to FastSync's current implementation | `--existing` | Skip creating new files on receiver | ✅ Implemented | Existing destination files continue through normal update handling | | `--ignore-existing` | Skip updating existing files | ❌ Not Implemented | | | `--remove-source-files` | Sender removes regular files after confirmed transfer | ✅ Implemented | | -| `-x`, `--one-file-system` | Do not cross filesystem boundaries | ✅ Implemented | Sender scanner captures the root device and skips descending into mount-point crossings (`st_dev` differs) | +| `-x`, `--one-file-system` | Do not cross filesystem boundaries | ✅ Implemented | Sender scanner captures the root device and skips descending into mount-point crossings (`st_dev` differs); cross-filesystem mount-point subdirectories are dropped entirely, matching rsync | | `-F` | Add the default `.rsync-filter` rules | ❌ Not Implemented | | ## 4. Directory Options diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index be452bc..1a10fbd 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -1064,6 +1064,8 @@ class TestOneFileSystem: dest_plain = os.path.join(TEST_DATA_DIR, "ofs_mnt_plain_dst") mountpoint = os.path.join(source, "external") clean_dir(source) + clean_dir(dest) + clean_dir(dest_plain) os.makedirs(mountpoint) os.makedirs(os.path.join(source, "nested")) with open(os.path.join(source, "root.txt"), "wb") as f: @@ -1071,6 +1073,7 @@ class TestOneFileSystem: with open(os.path.join(source, "nested", "inner.txt"), "wb") as f: f.write(b"inner") mounted = False + unmount_error = "" try: mount = subprocess.run(["mount", "-t", "tmpfs", "tmpfs", mountpoint], capture_output=True, text=True) @@ -1093,4 +1096,8 @@ class TestOneFileSystem: "without -x the mounted subtree must be transferred" finally: if mounted: - subprocess.run(["umount", mountpoint], capture_output=True, text=True) + umount = subprocess.run(["umount", mountpoint], capture_output=True, text=True) + if umount.returncode != 0: + unmount_error = umount.stderr.strip() + if unmount_error: + pytest.fail(f"test mountpoint {mountpoint} still mounted after umount: {unmount_error}") diff --git a/tests/test_scanner.c b/tests/test_scanner.c index e1ad0ed..2b736a5 100644 --- a/tests/test_scanner.c +++ b/tests/test_scanner.c @@ -504,6 +504,155 @@ static void test_parallel_scanner_one_file_system_same_device() { rmdir(root); } +/* Scan a tree with copy_links semantics, collecting every emitted path. + * Returns 0 on success, -1 on scanner failure. */ +static int collect_directory_scan(const char* root, bool one_file_system, const char* needle, + bool* found, int* total) { + ScannerOptions options = {0}; + options.copy_links = true; + options.one_file_system = one_file_system; + DirectoryScanner* scanner = directory_scanner_create_with_options(root, &options); + if (!scanner) + return -1; + *found = false; + *total = 0; + Chunk* chunk; + while ((chunk = directory_scanner_next(scanner)) != NULL) { + for (int i = 0; i < chunk->element_count; i++) { + (*total)++; + if (strstr(chunk->items[i]->path, needle) != NULL) + *found = true; + } + chunk_destroy(chunk); + } + bool failed = directory_scanner_failed(scanner); + directory_scanner_destroy(scanner); + return failed ? -1 : 0; +} + +static int collect_parallel_scan(const char* root, bool one_file_system, const char* needle, + bool* found, int* total) { + ScannerOptions options = {0}; + options.copy_links = true; + options.one_file_system = one_file_system; + options.num_threads = 2; + ParallelScanner* scanner = parallel_scanner_create_with_options(root, &options, NULL); + if (!scanner) + return -1; + *found = false; + *total = 0; + Chunk* chunk; + while ((chunk = parallel_scanner_next(scanner)) != NULL) { + for (int i = 0; i < chunk->element_count; i++) { + (*total)++; + if (strstr(chunk->items[i]->path, needle) != NULL) + *found = true; + } + chunk_destroy(chunk); + } + bool failed = parallel_scanner_failed(scanner); + parallel_scanner_destroy(scanner); + return failed ? -1 : 0; +} + +/* Rootless cross-filesystem test: a symlink nested under the scan root points + * at a directory on another device (typically /dev/shm, a tmpfs distinct from + * the build filesystem). With --copy-links semantics the scanner resolves the + * link and must descend into it only when -x is off. The nested placement + * exercises the skip decision in the sequential walker and in the parallel + * worker (depth > 1). Skips when no cross-device target is available. */ +static void test_scanner_one_file_system_cross_device() { + struct stat local_stat; + if (stat(".", &local_stat) != 0) + return; + + char shm_dir[64] = "/dev/shm/fastsync_ofs_shm_XXXXXX"; + if (mkdtemp(shm_dir) == NULL) + return; + struct stat shm_stat; + if (stat(shm_dir, &shm_stat) != 0 || shm_stat.st_dev == local_stat.st_dev) { + rmdir(shm_dir); + return; + } + + char root_dir[64] = "./fastsync_ofs_root_XXXXXX"; + if (mkdtemp(root_dir) == NULL) { + rmdir(shm_dir); + return; + } + + char nested[96]; + snprintf(nested, sizeof(nested), "%s/nested", root_dir); + char link_path[128]; + snprintf(link_path, sizeof(link_path), "%s/link", nested); + char root_file[96]; + snprintf(root_file, sizeof(root_file), "%s/keep.txt", root_dir); + char shm_file[96]; + snprintf(shm_file, sizeof(shm_file), "%s/inside.txt", shm_dir); + + bool ready = mkdir(nested, 0755) == 0 && symlink(shm_dir, link_path) == 0; + if (ready) + create_test_file(root_file, "keep"); + if (ready) + create_test_file(shm_file, "cross"); + + int rc, total; + bool found; + int seq_off_rc, seq_off_total, seq_on_rc, seq_on_total; + bool seq_off_found, seq_on_found; + int par_off_rc, par_off_total, par_on_rc, par_on_total; + bool par_off_found, par_on_found; + if (!ready) { + seq_off_rc = seq_on_rc = par_off_rc = par_on_rc = -1; + seq_off_total = seq_on_total = par_off_total = par_on_total = 0; + seq_off_found = seq_on_found = par_off_found = par_on_found = false; + } else { + rc = collect_directory_scan(root_dir, false, "inside.txt", &found, &total); + seq_off_rc = rc; + seq_off_total = total; + seq_off_found = found; + rc = collect_directory_scan(root_dir, true, "inside.txt", &found, &total); + seq_on_rc = rc; + seq_on_total = total; + seq_on_found = found; + rc = collect_parallel_scan(root_dir, false, "inside.txt", &found, &total); + par_off_rc = rc; + par_off_total = total; + par_off_found = found; + rc = collect_parallel_scan(root_dir, true, "inside.txt", &found, &total); + par_on_rc = rc; + par_on_total = total; + par_on_found = found; + } + + /* Hermetic cleanup regardless of scan outcome, before any assertions. */ + unlink(shm_file); + rmdir(shm_dir); + unlink(link_path); + unlink(root_file); + rmdir(nested); + rmdir(root_dir); + + if (!ready) + return; + + /* Sequential: without -x the symlinked foreign subtree is included. */ + EXPECT_EQ_INT(seq_off_rc, 0); + EXPECT_TRUE(seq_off_found); + EXPECT_EQ_INT(seq_off_total, 2); + /* Sequential: with -x the cross-device subtree is dropped, keep.txt remains. */ + EXPECT_EQ_INT(seq_on_rc, 0); + EXPECT_FALSE(seq_on_found); + EXPECT_EQ_INT(seq_on_total, 1); + /* Parallel: same behavior, worker path (depth > 1). */ + EXPECT_EQ_INT(par_off_rc, 0); + EXPECT_TRUE(par_off_found); + EXPECT_EQ_INT(par_off_total, 2); + EXPECT_EQ_INT(par_on_rc, 0); + EXPECT_FALSE(par_on_found); + EXPECT_EQ_INT(par_on_total, 1); +} + void test_scanner() { test_scanner_single_file(); test_scanner_multiple_files(); @@ -522,4 +671,5 @@ void test_scanner() { test_scanner_one_file_system_decision(); test_scanner_one_file_system_same_device(); test_parallel_scanner_one_file_system_same_device(); + test_scanner_one_file_system_cross_device(); }