feat: implement rsync --temp-dir for atomic receiver installs
This commit is contained in:
@@ -1021,3 +1021,90 @@ 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)
|
||||
|
||||
|
||||
def _walk_tmp_files(root):
|
||||
"""Recursively list *.tmp* leftovers under root (empty if root missing)."""
|
||||
leftovers = []
|
||||
if not os.path.isdir(root):
|
||||
return leftovers
|
||||
for base, _, files in os.walk(root):
|
||||
for name in files:
|
||||
if ".tmp." in name:
|
||||
leftovers.append(os.path.join(base, name))
|
||||
return leftovers
|
||||
|
||||
|
||||
class TestTempDir:
|
||||
"""--temp-dir=DIR puts the receiver's temporary working copies in a scratch
|
||||
directory below the destination root and atomically renames each completed
|
||||
file into its final destination. Files sharing a basename across
|
||||
directories exercise the flat scratch namespace."""
|
||||
|
||||
def _make_source(self, name):
|
||||
source = os.path.join(TEST_DATA_DIR, name)
|
||||
clean_dir(source)
|
||||
entries = {
|
||||
"top.txt": b"top level\n",
|
||||
"sub/file.txt": b"nested file\n" * 20,
|
||||
"other/file.txt": b"other nested file\n",
|
||||
"sub/deep.bin": bytes(range(256)) * 8,
|
||||
}
|
||||
for rel, content in entries.items():
|
||||
full = os.path.join(source, rel)
|
||||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||||
with open(full, "wb") as fh:
|
||||
fh.write(content)
|
||||
return source
|
||||
|
||||
def _assert_clean_scratch(self, scratch):
|
||||
assert os.path.isdir(scratch), f"scratch dir {scratch} was not created"
|
||||
leftovers = _walk_tmp_files(scratch)
|
||||
assert leftovers == [], f"leftover temp files in scratch dir: {leftovers}"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_temp_dir_scratch(self, shared_server, mt):
|
||||
source = self._make_source("tempdir_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "tempdir_dst")
|
||||
clean_dir(dest)
|
||||
flags = ["--temp-dir=scratch"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, f"temp-dir 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}"
|
||||
self._assert_clean_scratch(os.path.join(dest, "scratch"))
|
||||
|
||||
def test_default_behavior_has_no_scratch_dir(self, shared_server):
|
||||
source = self._make_source("tempdir_default_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "tempdir_default_dst")
|
||||
clean_dir(dest)
|
||||
result, _ = run_client(source, dest, port=shared_server.port)
|
||||
assert result.returncode == 0, f"Default 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}"
|
||||
assert not os.path.exists(os.path.join(dest, "scratch"))
|
||||
|
||||
def test_temp_dir_escape_rejected(self, shared_server):
|
||||
source = self._make_source("tempdir_escape_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "tempdir_escape_dst")
|
||||
clean_dir(dest)
|
||||
# "../escape" would resolve one level above the destination root.
|
||||
outside = os.path.join(TEST_DATA_DIR, "escape")
|
||||
assert not os.path.lexists(outside)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir=../escape"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, "relative escaping --temp-dir was not rejected"
|
||||
assert not os.path.lexists(outside), "file created outside the destination root"
|
||||
|
||||
clean_dir(dest)
|
||||
abs_escape = os.path.join(TEST_DATA_DIR, "abs_escape_probe")
|
||||
assert not os.path.lexists(abs_escape)
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir", abs_escape],
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, "absolute --temp-dir was not rejected"
|
||||
assert not os.path.lexists(abs_escape), "file created outside the destination root"
|
||||
|
||||
+29
-1
@@ -620,7 +620,6 @@ static void test_parse_args_rejects_unimplemented_options() {
|
||||
"-e",
|
||||
"--rsh",
|
||||
"--rsync-path",
|
||||
"--temp-dir",
|
||||
"--compare-dest",
|
||||
"--copy-dest",
|
||||
"--link-dest",
|
||||
@@ -840,6 +839,34 @@ static void test_parse_args_checksum_choice_requires_value() {
|
||||
}
|
||||
}
|
||||
|
||||
/* --temp-dir accepts both the "--temp-dir=DIR" and "--temp-dir DIR" forms. */
|
||||
static void test_parse_args_temp_dir() {
|
||||
Config* cfg = config_create();
|
||||
char* equals_argv[] = {"fastsync", "--temp-dir=scratch", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, equals_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->temp_dir, "scratch");
|
||||
EXPECT_EQ_INT(positional_count, 2);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* space_argv[] = {"fastsync", "--temp-dir", "scratch/sub", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, space_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->temp_dir, "scratch/sub");
|
||||
EXPECT_EQ_INT(positional_count, 2);
|
||||
config_delete(cfg);
|
||||
|
||||
/* A value-taking option may not be passed without a value. */
|
||||
cfg = config_create();
|
||||
char* missing_argv[] = {"fastsync", "--temp-dir"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 2, missing_argv, positional_args, &positional_count), -1);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
static void test_parse_args_old_args() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--old-args", "/src", "/dst"};
|
||||
@@ -1225,4 +1252,5 @@ void test_client_cli() {
|
||||
test_parse_args_partial_progress();
|
||||
test_parse_args_checksum_choice_aliases();
|
||||
test_parse_args_checksum_choice_requires_value();
|
||||
test_parse_args_temp_dir();
|
||||
}
|
||||
|
||||
@@ -381,6 +381,30 @@ static void test_config_string_null_vs_empty_roundtrip() {
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
/* A --temp-dir value must survive config_send/config_receive unchanged on the
|
||||
receive side (round-trips through the resume-options wire block). */
|
||||
static void test_config_temp_dir_roundtrip() {
|
||||
if (is_running_under_valgrind())
|
||||
return;
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
c->temp_dir = str_dup("scratch");
|
||||
EXPECT_TRUE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
|
||||
/* An empty-STRING wire value is canonicalized back to NULL (never an empty
|
||||
scratch-dir name). */
|
||||
c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
c->temp_dir = str_dup("");
|
||||
EXPECT_TRUE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
static void test_config_is_remote_dest() {
|
||||
/* Valid SSH-style destinations */
|
||||
EXPECT_TRUE(config_is_remote_dest("user@host:/path"));
|
||||
@@ -415,6 +439,7 @@ void test_config() {
|
||||
test_config_send_receive_version_mismatch();
|
||||
test_config_receive_truncated();
|
||||
test_config_string_null_vs_empty_roundtrip();
|
||||
test_config_temp_dir_roundtrip();
|
||||
}
|
||||
test_config_is_remote_dest();
|
||||
}
|
||||
|
||||
+1
-1
@@ -381,7 +381,7 @@ static void test_file_write_to_disk_with_fsync() {
|
||||
const char* path = "test_file_write_to_disk_fsync.txt";
|
||||
const char* content = "fsync file content";
|
||||
EXPECT_TRUE(file_to_disk_secure_with_fsync(path, content, strlen(content), false, false, NULL,
|
||||
false, true));
|
||||
false, true, NULL));
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_size, (int)strlen(content));
|
||||
|
||||
Reference in New Issue
Block a user