feat: add -x/--one-file-system to stay within the source filesystem
Capture the transfer root's device (st_dev) at scanner creation and skip descending into any subdirectory on a different device (a mount point). Implemented sender/client-side only: sequential BFS and parallel (-m) root scan apply the same scanner_same_filesystem decision; no wire/protocol change and default behavior is unchanged. Unit tests cover the pure decision, same device scanning in both modes, and CLI parsing; integration tests prove -x leaves a single-filesystem tree byte-identical and, when root can mount a tmpfs, skips a genuine cross-device subtree.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import filecmp
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import pytest
|
||||
@@ -1021,3 +1022,75 @@ class TestLargeFile:
|
||||
assert result.returncode == 0, f"Large-file sync failed: {result.stderr[:200]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert filecmp.cmp(source_file, os.path.join(received, "big.bin"), shallow=False)
|
||||
|
||||
|
||||
class TestOneFileSystem:
|
||||
def _make_tree(self, source):
|
||||
clean_dir(source)
|
||||
os.makedirs(os.path.join(source, "nested", "deeper"))
|
||||
with open(os.path.join(source, "root.txt"), "wb") as f:
|
||||
f.write(b"root")
|
||||
with open(os.path.join(source, "nested", "inner.txt"), "wb") as f:
|
||||
f.write(b"inner")
|
||||
with open(os.path.join(source, "nested", "deeper", "deep.txt"), "wb") as f:
|
||||
f.write(b"deep")
|
||||
|
||||
def _assert_full_tree_transferred(self, source, dest, port, flags):
|
||||
clean_dir(dest)
|
||||
result, _ = run_client(source, dest, flags=flags, port=port)
|
||||
assert result.returncode == 0, f"Sync failed: {result.stderr[:200]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
mismatches, missing = verify_transfer(source, received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
|
||||
def test_x_transfer_matches_plain_over_single_filesystem(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "ofs_src")
|
||||
self._make_tree(source)
|
||||
self._assert_full_tree_transferred(source, os.path.join(TEST_DATA_DIR, "ofs_dst"),
|
||||
shared_server.port, ["-x"])
|
||||
|
||||
def test_x_multithreaded_transfer_matches_plain_over_single_filesystem(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "ofs_m_src")
|
||||
self._make_tree(source)
|
||||
self._assert_full_tree_transferred(source, os.path.join(TEST_DATA_DIR, "ofs_m_dst"),
|
||||
shared_server.port, ["-m", "--one-file-system"])
|
||||
|
||||
def test_x_skips_other_device_mountpoint(self, shared_server):
|
||||
if os.geteuid() != 0 or shutil.which("mount") is None or shutil.which("umount") is None:
|
||||
pytest.skip("cross-device test requires root and mount(8)")
|
||||
source = os.path.join(TEST_DATA_DIR, "ofs_mnt_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "ofs_mnt_dst")
|
||||
dest_plain = os.path.join(TEST_DATA_DIR, "ofs_mnt_plain_dst")
|
||||
mountpoint = os.path.join(source, "external")
|
||||
clean_dir(source)
|
||||
os.makedirs(mountpoint)
|
||||
os.makedirs(os.path.join(source, "nested"))
|
||||
with open(os.path.join(source, "root.txt"), "wb") as f:
|
||||
f.write(b"root")
|
||||
with open(os.path.join(source, "nested", "inner.txt"), "wb") as f:
|
||||
f.write(b"inner")
|
||||
mounted = False
|
||||
try:
|
||||
mount = subprocess.run(["mount", "-t", "tmpfs", "tmpfs", mountpoint],
|
||||
capture_output=True, text=True)
|
||||
if mount.returncode != 0:
|
||||
pytest.skip(f"cannot mount tmpfs: {mount.stderr.strip()}")
|
||||
mounted = True
|
||||
with open(os.path.join(mountpoint, "away.txt"), "wb") as f:
|
||||
f.write(b"cross device")
|
||||
result, _ = run_client(source, dest, flags=["-x"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"-x sync failed: {result.stderr[:200]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert os.path.isfile(os.path.join(received, "root.txt"))
|
||||
assert os.path.isfile(os.path.join(received, "nested", "inner.txt"))
|
||||
assert not os.path.exists(os.path.join(received, "external", "away.txt")), \
|
||||
"-x must not cross into the mounted filesystem"
|
||||
result, _ = run_client(source, dest_plain, port=shared_server.port)
|
||||
assert result.returncode == 0, f"plain sync failed: {result.stderr[:200]}"
|
||||
received_plain = get_dest_received_dir(dest_plain, source)
|
||||
assert os.path.isfile(os.path.join(received_plain, "external", "away.txt")), \
|
||||
"without -x the mounted subtree must be transferred"
|
||||
finally:
|
||||
if mounted:
|
||||
subprocess.run(["umount", mountpoint], capture_output=True, text=True)
|
||||
|
||||
@@ -985,6 +985,35 @@ static void test_parse_args_whole_file() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* -x and --one-file-system enable client-side single-filesystem scanning. */
|
||||
static void test_parse_args_one_file_system() {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_FALSE(cfg->one_file_system);
|
||||
|
||||
char* argv[] = {"fastsync", "-x", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->one_file_system);
|
||||
EXPECT_EQ_INT(positional_count, 2);
|
||||
|
||||
config_delete(cfg);
|
||||
cfg = config_create();
|
||||
char* long_argv[] = {"fastsync", "--one-file-system", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, long_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->one_file_system);
|
||||
|
||||
config_delete(cfg);
|
||||
cfg = config_create();
|
||||
/* Flags never take a value: the "=value" form must be rejected. */
|
||||
char* bad_argv[] = {"fastsync", "--one-file-system=yes", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, bad_argv, positional_args, &positional_count), -1);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* Test rsync-compatible compression-choice and compression-level aliases. */
|
||||
static void test_parse_args_compression_aliases() {
|
||||
Config* cfg = config_create();
|
||||
@@ -1213,6 +1242,7 @@ void test_client_cli() {
|
||||
test_parse_args_secluded_args();
|
||||
test_parse_args_short_s_remains_chunk_serialization();
|
||||
test_parse_args_whole_file();
|
||||
test_parse_args_one_file_system();
|
||||
test_parse_args_compression_aliases();
|
||||
test_parse_args_compression_equals_and_none();
|
||||
test_parse_args_compression_canonical_equals();
|
||||
|
||||
+95
-2
@@ -394,8 +394,8 @@ static void test_parallel_scanner_root_chunks_without_workers() {
|
||||
create_test_file(file1, "a");
|
||||
create_test_file(file2, "b");
|
||||
|
||||
ScannerOptions options = {false, 1, NULL, 0, NULL, 0, 0, 0,
|
||||
0, 0, false, false, false, false, false};
|
||||
ScannerOptions options = {false, 1, NULL, 0, NULL, 0, 0, 0,
|
||||
0, 0, false, false, false, false, false, false};
|
||||
ParallelScanner* scanner = parallel_scanner_create_with_options(dir, &options, NULL);
|
||||
EXPECT_NOT_NULL(scanner);
|
||||
|
||||
@@ -414,6 +414,96 @@ static void test_parallel_scanner_root_chunks_without_workers() {
|
||||
rmdir(dir);
|
||||
}
|
||||
|
||||
/* --one-file-system (-x) decision is a pure device comparison. */
|
||||
static void test_scanner_one_file_system_decision() {
|
||||
/* Option disabled: every device is allowed (unchanged default behavior). */
|
||||
EXPECT_TRUE(scanner_same_filesystem(false, 0, 123));
|
||||
EXPECT_TRUE(scanner_same_filesystem(false, 7, 999));
|
||||
/* Option enabled: only entries on the root device may be descended into. */
|
||||
EXPECT_TRUE(scanner_same_filesystem(true, 7, 7));
|
||||
EXPECT_FALSE(scanner_same_filesystem(true, 7, 8));
|
||||
}
|
||||
|
||||
/* With -x over an ordinary tree (all one device) nothing may be skipped. */
|
||||
static void test_scanner_one_file_system_same_device() {
|
||||
const char* root = "test_scan_ofs";
|
||||
const char* sub = "test_scan_ofs/sub";
|
||||
const char* deeper = "test_scan_ofs/sub/deeper";
|
||||
const char* root_file = "test_scan_ofs/root.txt";
|
||||
const char* sub_file = "test_scan_ofs/sub/inner.txt";
|
||||
const char* deep_file = "test_scan_ofs/sub/deeper/deep.txt";
|
||||
|
||||
EXPECT_EQ_INT(mkdir(root, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir(sub, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir(deeper, 0755), 0);
|
||||
create_test_file(root_file, "root");
|
||||
create_test_file(sub_file, "inner");
|
||||
create_test_file(deep_file, "deep");
|
||||
|
||||
ScannerOptions options = {0};
|
||||
options.one_file_system = true;
|
||||
DirectoryScanner* scanner = directory_scanner_create_with_options(root, &options);
|
||||
EXPECT_NOT_NULL(scanner);
|
||||
|
||||
int total_files = 0;
|
||||
Chunk* chunk;
|
||||
while ((chunk = directory_scanner_next(scanner)) != NULL) {
|
||||
total_files += chunk->element_count;
|
||||
chunk_destroy(chunk);
|
||||
}
|
||||
EXPECT_EQ_INT(total_files, 3);
|
||||
EXPECT_FALSE(directory_scanner_failed(scanner));
|
||||
|
||||
directory_scanner_destroy(scanner);
|
||||
unlink(root_file);
|
||||
unlink(sub_file);
|
||||
unlink(deep_file);
|
||||
rmdir(deeper);
|
||||
rmdir(sub);
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* Multithreaded (-m) scan with -x over a single-device tree must match the
|
||||
* single-threaded result. */
|
||||
static void test_parallel_scanner_one_file_system_same_device() {
|
||||
const char* root = "test_parallel_scan_ofs";
|
||||
const char* sub = "test_parallel_scan_ofs/sub";
|
||||
const char* sub2 = "test_parallel_scan_ofs/sub2";
|
||||
const char* root_file = "test_parallel_scan_ofs/root.txt";
|
||||
const char* sub_file = "test_parallel_scan_ofs/sub/inner.txt";
|
||||
const char* sub2_file = "test_parallel_scan_ofs/sub2/inner2.txt";
|
||||
|
||||
EXPECT_EQ_INT(mkdir(root, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir(sub, 0755), 0);
|
||||
EXPECT_EQ_INT(mkdir(sub2, 0755), 0);
|
||||
create_test_file(root_file, "root");
|
||||
create_test_file(sub_file, "inner");
|
||||
create_test_file(sub2_file, "inner2");
|
||||
|
||||
ScannerOptions options = {0};
|
||||
options.one_file_system = true;
|
||||
options.num_threads = 2;
|
||||
ParallelScanner* scanner = parallel_scanner_create_with_options(root, &options, NULL);
|
||||
EXPECT_NOT_NULL(scanner);
|
||||
|
||||
int total_files = 0;
|
||||
Chunk* chunk;
|
||||
while ((chunk = parallel_scanner_next(scanner)) != NULL) {
|
||||
total_files += chunk->element_count;
|
||||
chunk_destroy(chunk);
|
||||
}
|
||||
EXPECT_EQ_INT(total_files, 3);
|
||||
EXPECT_FALSE(parallel_scanner_failed(scanner));
|
||||
|
||||
parallel_scanner_destroy(scanner);
|
||||
unlink(root_file);
|
||||
unlink(sub_file);
|
||||
unlink(sub2_file);
|
||||
rmdir(sub);
|
||||
rmdir(sub2);
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
void test_scanner() {
|
||||
test_scanner_single_file();
|
||||
test_scanner_multiple_files();
|
||||
@@ -429,4 +519,7 @@ void test_scanner() {
|
||||
test_scanner_mixed_patterns();
|
||||
test_scanner_no_patterns();
|
||||
test_parallel_scanner_root_chunks_without_workers();
|
||||
test_scanner_one_file_system_decision();
|
||||
test_scanner_one_file_system_same_device();
|
||||
test_parallel_scanner_one_file_system_same_device();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user