test: cover -x cross-device skip without root; harden OneFileSystem tests
CI / lint (pull_request) Failing after 20s
CI / build-and-test (pull_request) Skipped
CI / sanitizers (address) (pull_request) Skipped
CI / sanitizers (undefined) (pull_request) Skipped
CI / fuzz-build (pull_request) Skipped
CI / coverage (pull_request) Skipped
CI / valgrind (pull_request) Skipped
CI / lint (pull_request) Failing after 20s
CI / build-and-test (pull_request) Skipped
CI / sanitizers (address) (pull_request) Skipped
CI / sanitizers (undefined) (pull_request) Skipped
CI / fuzz-build (pull_request) Skipped
CI / coverage (pull_request) Skipped
CI / valgrind (pull_request) Skipped
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).
This commit is contained in:
+1
-1
@@ -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 |
|
| `--existing` | Skip creating new files on receiver | ✅ Implemented | Existing destination files continue through normal update handling |
|
||||||
| `--ignore-existing` | Skip updating existing files | ❌ Not Implemented | |
|
| `--ignore-existing` | Skip updating existing files | ❌ Not Implemented | |
|
||||||
| `--remove-source-files` | Sender removes regular files after confirmed transfer | ✅ 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 | |
|
| `-F` | Add the default `.rsync-filter` rules | ❌ Not Implemented | |
|
||||||
|
|
||||||
## 4. Directory Options
|
## 4. Directory Options
|
||||||
|
|||||||
@@ -1064,6 +1064,8 @@ class TestOneFileSystem:
|
|||||||
dest_plain = os.path.join(TEST_DATA_DIR, "ofs_mnt_plain_dst")
|
dest_plain = os.path.join(TEST_DATA_DIR, "ofs_mnt_plain_dst")
|
||||||
mountpoint = os.path.join(source, "external")
|
mountpoint = os.path.join(source, "external")
|
||||||
clean_dir(source)
|
clean_dir(source)
|
||||||
|
clean_dir(dest)
|
||||||
|
clean_dir(dest_plain)
|
||||||
os.makedirs(mountpoint)
|
os.makedirs(mountpoint)
|
||||||
os.makedirs(os.path.join(source, "nested"))
|
os.makedirs(os.path.join(source, "nested"))
|
||||||
with open(os.path.join(source, "root.txt"), "wb") as f:
|
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:
|
with open(os.path.join(source, "nested", "inner.txt"), "wb") as f:
|
||||||
f.write(b"inner")
|
f.write(b"inner")
|
||||||
mounted = False
|
mounted = False
|
||||||
|
unmount_error = ""
|
||||||
try:
|
try:
|
||||||
mount = subprocess.run(["mount", "-t", "tmpfs", "tmpfs", mountpoint],
|
mount = subprocess.run(["mount", "-t", "tmpfs", "tmpfs", mountpoint],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True)
|
||||||
@@ -1093,4 +1096,8 @@ class TestOneFileSystem:
|
|||||||
"without -x the mounted subtree must be transferred"
|
"without -x the mounted subtree must be transferred"
|
||||||
finally:
|
finally:
|
||||||
if mounted:
|
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}")
|
||||||
|
|||||||
@@ -504,6 +504,155 @@ static void test_parallel_scanner_one_file_system_same_device() {
|
|||||||
rmdir(root);
|
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() {
|
void test_scanner() {
|
||||||
test_scanner_single_file();
|
test_scanner_single_file();
|
||||||
test_scanner_multiple_files();
|
test_scanner_multiple_files();
|
||||||
@@ -522,4 +671,5 @@ void test_scanner() {
|
|||||||
test_scanner_one_file_system_decision();
|
test_scanner_one_file_system_decision();
|
||||||
test_scanner_one_file_system_same_device();
|
test_scanner_one_file_system_same_device();
|
||||||
test_parallel_scanner_one_file_system_same_device();
|
test_parallel_scanner_one_file_system_same_device();
|
||||||
|
test_scanner_one_file_system_cross_device();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user