fix(parity): receiver temp-dir confinement, server I/O floor, delete budget
Address review findings on feat/rsync-parity: - confine --temp-dir below the receive root (reject absolute/.. like backup-dir/partial-dir); keep EXDEV non-atomic fallback - floor server session I/O deadlines at SERVER_IO_TIMEOUT_SEC (60s) and install it on the socket layer at startup (slow-loris) - charge each --delete-missing-args directory removal once and clamp the extras-walk remaining budget so it can never underflow past --max-delete - normalize --compress-choice=auto to zstd client-side and accept it on receive so auto transfers no longer fail - map received --max-alloc=0 to MAX_SERVER_ALLOC (receive path only) - zero File.dest_state; include log-file-format in report_dest_info; add STATUS_DELETE_LIMIT name; recognize --skip-compress as a separate-value option; OOM-guard send_list_only root entry; drop the dead -M= branch; record the bare relative protected prefix for -R size-prunes in both scanners; refresh delete-manifest comment - pin the rsync tarball sha256 and bump integrator image to v11 Tests: temp-dir rejection/relative/cross-device, server timeout floor, delete-missing dir budget regression, compress-choice=auto e2e, max-alloc=0 receive mapping, dest_state, report_dest_info modes, skip-compress dash value, -M short forms, -R root size-prune mirror protection (rsync 3.4.1 confirmed).
This commit is contained in:
@@ -254,6 +254,13 @@ def _wait_for_port(port, timeout=5):
|
||||
|
||||
|
||||
def _wait_proc(proc, timeout=5):
|
||||
"""Stop a long-lived subprocess promptly. The server installs a SIGTERM
|
||||
handler, so signal first and only escalate to SIGKILL if it does not exit;
|
||||
waiting without signalling would burn the full timeout on every stop."""
|
||||
if proc.poll() is not None:
|
||||
proc.wait()
|
||||
return
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
|
||||
@@ -1379,6 +1379,20 @@ class TestChecksumChoice:
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, f"{bad} must be rejected"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_compress_choice_auto_transfers(self, shared_server):
|
||||
"""--compress-choice=auto is normalized to zstd client-side, so the
|
||||
receiver never rejects the transfer (#4)."""
|
||||
clean_dir(DEST_DIR)
|
||||
flags = ["-z", "--compress-choice=auto"]
|
||||
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--compress-choice=auto sync failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
mismatches, missing = verify_transfer(SOURCE_DIR, received)
|
||||
assert not missing, f"Missing: {missing}"
|
||||
assert not mismatches, f"Mismatch: {mismatches}"
|
||||
|
||||
@pytest.mark.parametrize("algo", ["xxh64", "xxh3", "xxh128", "md5"])
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_unchanged_skipped_and_bytes_preserved(self, shared_server, algo, mt):
|
||||
@@ -2215,17 +2229,10 @@ class TestTempDir:
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, "a missing relative --temp-dir must fail"
|
||||
|
||||
missing_abs = os.path.join(TEST_DATA_DIR, "no_such_abs_scratch")
|
||||
assert not os.path.lexists(missing_abs)
|
||||
clean_dir(dest)
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir", missing_abs],
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, "a missing absolute --temp-dir must fail"
|
||||
|
||||
def test_temp_dir_absolute_outside_root_is_used(self, shared_server):
|
||||
"""rsync accepts any temp dir, including one outside the destination
|
||||
tree; the completed files are still installed below the root and no
|
||||
temp files remain in the scratch dir."""
|
||||
def test_temp_dir_absolute_rejected(self, shared_server):
|
||||
"""The receiver confines --temp-dir to the destination root: an absolute
|
||||
(or `..`-escaping) value is rejected before any write, so a client can
|
||||
never make the receiver create scratch files in an arbitrary directory."""
|
||||
source = self._make_source("tempdir_abs_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "tempdir_abs_dst")
|
||||
clean_dir(dest)
|
||||
@@ -2235,13 +2242,14 @@ class TestTempDir:
|
||||
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir", scratch],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, f"absolute 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(scratch)
|
||||
assert result.returncode != 0, "an absolute --temp-dir must be rejected"
|
||||
assert os.listdir(scratch) == [], "receiver wrote into an unconfined temp dir"
|
||||
# A relative traversal is rejected for the same reason.
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir=../escape_scratch"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, "a `..` --temp-dir must be rejected"
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
shutil.rmtree(os.path.join(TEST_DATA_DIR, "escape_scratch"), ignore_errors=True)
|
||||
|
||||
|
||||
class TestTimeoutAndAllocLimits:
|
||||
@@ -2285,7 +2293,8 @@ class TestTimeoutAndAllocLimits:
|
||||
assert not missing and not mismatches
|
||||
|
||||
def test_temp_dir_cross_filesystem_fallback(self, shared_server):
|
||||
"""A --temp-dir on another filesystem must fall back to a non-atomic
|
||||
"""A confined relative --temp-dir that resolves (via a symlink under the
|
||||
destination root) to another filesystem must fall back to a non-atomic
|
||||
copy instead of aborting (rsync parity). Skipped when no second
|
||||
filesystem is available."""
|
||||
shm = "/dev/shm"
|
||||
@@ -2298,14 +2307,18 @@ class TestTimeoutAndAllocLimits:
|
||||
os.makedirs(scratch)
|
||||
try:
|
||||
source, dest = self._seed("tempdir_xdev_src")
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir", scratch],
|
||||
# The receiver resolves a relative temp dir under the destination
|
||||
# root; a symlink there points the scratch at the second filesystem.
|
||||
link = os.path.join(dest, "xdev_scratch")
|
||||
os.symlink(scratch, link)
|
||||
result, _ = run_client(source, dest, flags=["--temp-dir", "xdev_scratch"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, f"cross-fs temp-dir failed: {result.stderr[:300]}"
|
||||
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 os.listdir(scratch) == [], "temp files left behind"
|
||||
assert os.listdir(scratch) == [], "temp files left behind in the cross-fs scratch"
|
||||
finally:
|
||||
shutil.rmtree(scratch, ignore_errors=True)
|
||||
|
||||
@@ -2910,6 +2923,43 @@ class TestRelativeFilesFrom:
|
||||
assert not os.path.exists(os.path.join(dest, "sub", "y.txt")), \
|
||||
"directory-listed --delete did not remove the in-scope extra"
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_relative_root_size_prune_protects_mirror_from_delete(self, mt):
|
||||
"""#12: a root-level --max-size prune under -R + --files-from must record
|
||||
the bare relative wire path as its delete-protected prefix, so the
|
||||
size-pruned entry's destination mirror survives --delete (rsync parity)."""
|
||||
source = _make_relative_source("rel_rootsize_src")
|
||||
# Big enough that a 100-byte cap prunes only this entry.
|
||||
with open(os.path.join(source, "big.txt"), "wb") as fh:
|
||||
fh.write(b"b" * 1000)
|
||||
dest = os.path.join(TEST_DATA_DIR, "rel_rootsize_dst")
|
||||
clean_dir(dest)
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
# "." lists the whole tree, so the receive root is a delete scope
|
||||
# (a file-only list would leave the root out of scope, masking the
|
||||
# protected-prefix mismatch this test targets).
|
||||
lst = _write_rel_list(b".\n")
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--files-from", lst, "-R"] + (["--threads"] if mt else []),
|
||||
port=server.port)
|
||||
assert result.returncode == 0, f"seed -R sync failed: {result.stderr[:200]}"
|
||||
assert os.path.isfile(os.path.join(dest, "big.txt"))
|
||||
with open(os.path.join(dest, "unrelated.txt"), "w") as fh:
|
||||
fh.write("x")
|
||||
|
||||
# --max-size=100 prunes only big.txt; its dest mirror is always protected.
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--files-from", lst, "-R", "--delete", "--max-size=100"] +
|
||||
(["--threads"] if mt else []),
|
||||
port=server.port)
|
||||
assert result.returncode == 0, f"-R size+delete sync failed: {result.stderr[:300]}"
|
||||
assert not os.path.exists(os.path.join(dest, "unrelated.txt")), "delete not active"
|
||||
assert os.path.isfile(os.path.join(dest, "big.txt")), \
|
||||
"the size-pruned entry's mirror was wrongly deleted (protected prefix mismatch)"
|
||||
assert os.path.isfile(os.path.join(dest, "sub", "x.txt"))
|
||||
assert os.path.isfile(os.path.join(dest, "top.txt"))
|
||||
|
||||
|
||||
class TestMissingArgs:
|
||||
"""--ignore-missing-args / --delete-missing-args: a --files-from entry that
|
||||
|
||||
@@ -1518,6 +1518,9 @@ static void test_parse_args_compress_choice_parity() {
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
/* "auto" is normalized to the canonical "zstd" the receiver accepts. */
|
||||
EXPECT_EQ_STR(cfg->compress_choice, strcmp(good[i], "auto") == 0 ? "zstd" : good[i]);
|
||||
EXPECT_EQ_INT(cfg->use_compression, strcmp(good[i], "none") != 0 ? 1 : 0);
|
||||
config_delete(cfg);
|
||||
}
|
||||
static const char* const bad[] = {"lz4", "zlib", "zlibx", "bogus"};
|
||||
@@ -2326,6 +2329,8 @@ static void test_parse_args_log_file_format() {
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->log_file_format, "%n %M");
|
||||
/* The format alone is inert (no --log-file): no destination report needed. */
|
||||
EXPECT_FALSE(cfg->report_dest_info);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
@@ -2334,6 +2339,61 @@ static void test_parse_args_log_file_format() {
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, separate_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->log_file_format, "%n %M");
|
||||
config_delete(cfg);
|
||||
|
||||
/* With --log-file the log-format is a real output mode whose %i/%n columns
|
||||
need the receiver's destination snapshot (same as -i/--out-format). */
|
||||
const char* log_path = "cli_log_fmt_test.txt";
|
||||
cfg = config_create();
|
||||
char log_arg[64];
|
||||
snprintf(log_arg, sizeof(log_arg), "--log-file=%s", log_path);
|
||||
char* both_argv[] = {"fastsync", log_arg, "--log-file-format=%i %n", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, both_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_NOT_NULL(cfg->log_file);
|
||||
EXPECT_TRUE(cfg->report_dest_info);
|
||||
config_delete(cfg);
|
||||
remove(log_path);
|
||||
}
|
||||
|
||||
/* --skip-compress takes a separate value even when it starts with '-' (e.g. a
|
||||
* suffix typed as "-foo"); the cluster expander must copy it verbatim rather
|
||||
* than treat it as a short-option cluster. */
|
||||
static void test_parse_args_skip_compress_dash_value() {
|
||||
Config* cfg = config_create();
|
||||
char* argv[] = {"fastsync", "--skip-compress", "-foo/bar", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->skip_compress_set);
|
||||
EXPECT_EQ_INT(cfg->skip_compress_count, 2);
|
||||
EXPECT_EQ_STR(cfg->skip_compress_suffixes[0], "-foo");
|
||||
EXPECT_EQ_STR(cfg->skip_compress_suffixes[1], "bar");
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* -i and --out-format also request the destination snapshot. */
|
||||
static void test_parse_args_report_dest_info_modes() {
|
||||
Config* cfg = config_create();
|
||||
char* itemize_argv[] = {"fastsync", "-i", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 3, itemize_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->report_dest_info);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* out_argv[] = {"fastsync", "--out-format=%n", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 3, out_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->report_dest_info);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
char* plain_argv[] = {"fastsync", "/src", "/dst"};
|
||||
positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 3, plain_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(cfg->report_dest_info);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --delay-updates is a plain boolean receiver option. */
|
||||
@@ -3344,6 +3404,24 @@ static void test_parse_args_remote_option_multiple() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* The -M=value and -Mvalue short forms are expanded by the cluster expander to
|
||||
* "-M value" before parsing; both must still collect the remote option (there
|
||||
* is no dedicated -M= branch). */
|
||||
static void test_parse_args_remote_option_short_forms() {
|
||||
static const char* const forms[] = {"-M=--allow-delete", "-M--allow-delete"};
|
||||
for (size_t i = 0; i < sizeof(forms) / sizeof(forms[0]); i++) {
|
||||
Config* cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv[] = {"fastsync", "--source-dir", "/src", "--dest-dir", "/dst", (char*)forms[i]};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_INT(cfg->remote_option_count, 1);
|
||||
EXPECT_EQ_STR(cfg->remote_options[0], "--allow-delete");
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Space-separated form "--remote-option OPT" also parses. */
|
||||
static void test_parse_args_remote_option_space_form() {
|
||||
Config* cfg = valid_client_config();
|
||||
@@ -4225,6 +4303,8 @@ void test_client_cli() {
|
||||
test_parse_args_list_only();
|
||||
test_parse_args_out_format();
|
||||
test_parse_args_log_file_format();
|
||||
test_parse_args_report_dest_info_modes();
|
||||
test_parse_args_skip_compress_dash_value();
|
||||
test_parse_args_checksum_choice_aliases();
|
||||
test_parse_args_checksum_choice_requires_value();
|
||||
test_parse_args_checksum_choice_equals_forms();
|
||||
@@ -4253,6 +4333,7 @@ void test_client_cli() {
|
||||
test_parse_args_trust_sender_default_false();
|
||||
test_parse_args_trust_sender();
|
||||
test_parse_args_remote_option_multiple();
|
||||
test_parse_args_remote_option_short_forms();
|
||||
test_parse_args_remote_option_space_form();
|
||||
test_parse_args_remote_option_missing_value();
|
||||
test_parse_args_remote_option_rejects_bad_values();
|
||||
|
||||
@@ -552,6 +552,83 @@ static void test_config_send_receive() {
|
||||
}
|
||||
}
|
||||
|
||||
/* #5: a received --max-alloc=0 (rsync's "no limit") is floored to the server
|
||||
* ceiling on the receive path, so a client cannot disable it. */
|
||||
static void test_config_receive_max_alloc_zero_floored() {
|
||||
Config* send_cfg = config_create();
|
||||
EXPECT_NOT_NULL(send_cfg);
|
||||
send_cfg->send_directory = str_dup("/send/src");
|
||||
send_cfg->receive_root_directory = str_dup("/send/dst");
|
||||
send_cfg->max_alloc = 0;
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
io_set_fds(p[0], p[1]);
|
||||
io_set_bwlimit(0);
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
close(p[1]);
|
||||
io_set_fds(p[0], p[0]);
|
||||
Config* recv_cfg = config_receive(p[0]);
|
||||
bool ok = recv_cfg != NULL && recv_cfg->max_alloc == MAX_SERVER_ALLOC;
|
||||
config_delete(recv_cfg);
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
_exit(ok ? 0 : 1);
|
||||
} else {
|
||||
close(p[0]);
|
||||
io_set_fds(p[1], p[1]);
|
||||
bool sent = config_send(p[1], send_cfg);
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
config_delete(send_cfg);
|
||||
EXPECT_TRUE(sent);
|
||||
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* #4: a hostile/older client that still sends compress_choice=auto must be
|
||||
* accepted (as zstd) rather than failing the whole transfer. */
|
||||
static void test_config_receive_compress_choice_auto_canonicalized() {
|
||||
Config* send_cfg = config_create();
|
||||
EXPECT_NOT_NULL(send_cfg);
|
||||
send_cfg->send_directory = str_dup("/send/src");
|
||||
send_cfg->receive_root_directory = str_dup("/send/dst");
|
||||
free(send_cfg->compress_choice);
|
||||
send_cfg->compress_choice = str_dup("auto");
|
||||
|
||||
int p[2];
|
||||
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
|
||||
io_set_fds(p[0], p[1]);
|
||||
io_set_bwlimit(0);
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
close(p[1]);
|
||||
io_set_fds(p[0], p[0]);
|
||||
Config* recv_cfg = config_receive(p[0]);
|
||||
bool ok = recv_cfg != NULL && strcmp(recv_cfg->compress_choice, "zstd") == 0;
|
||||
config_delete(recv_cfg);
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
_exit(ok ? 0 : 1);
|
||||
} else {
|
||||
close(p[0]);
|
||||
io_set_fds(p[1], p[1]);
|
||||
bool sent = config_send(p[1], send_cfg);
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
close(p[0]);
|
||||
close(p[1]);
|
||||
config_delete(send_cfg);
|
||||
EXPECT_TRUE(sent);
|
||||
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
|
||||
}
|
||||
}
|
||||
|
||||
static void test_config_send_receive_version_mismatch() {
|
||||
/* A peer using the previous wire format must be rejected. */
|
||||
Config* cfg = config_create();
|
||||
@@ -3051,6 +3128,8 @@ void test_config() {
|
||||
test_pipeline_receiver_lifecycle();
|
||||
if (!is_running_under_valgrind()) {
|
||||
test_config_send_receive();
|
||||
test_config_receive_max_alloc_zero_floored();
|
||||
test_config_receive_compress_choice_auto_canonicalized();
|
||||
test_config_local_only_fields_not_serialized();
|
||||
test_config_send_receive_version_mismatch();
|
||||
test_config_receive_truncated();
|
||||
|
||||
@@ -28,6 +28,10 @@ static void test_file_create() {
|
||||
EXPECT_NULL(f->data->data);
|
||||
EXPECT_EQ_INT((int)f->data->size, 0);
|
||||
EXPECT_NULL(f->metadata);
|
||||
/* An unset destination snapshot must read as known == false, never
|
||||
indeterminate bytes (-i/--out-format without --incremental). */
|
||||
EXPECT_FALSE(f->dest_state.known);
|
||||
EXPECT_FALSE(f->dest_state.existed);
|
||||
file_destroy(f);
|
||||
}
|
||||
|
||||
@@ -326,6 +330,52 @@ static void test_file_save_to_disk_partial_install() {
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* --temp-dir is a client-controlled wire value that must be confined below the
|
||||
* receive root: an absolute or `..`-escaping value is rejected (a client must
|
||||
* never make the receiver write scratch files in an arbitrary directory), while
|
||||
* a relative one resolves under the root and is used for the atomic install. */
|
||||
static void test_file_save_to_disk_temp_dir_confined() {
|
||||
const char* root = "test_temp_confine_tmp";
|
||||
const char* dest_file = "test_temp_confine_tmp/file.txt";
|
||||
char outside[PATH_MAX];
|
||||
snprintf(outside, sizeof(outside), "/tmp/fastsync_temp_outside_%d", (int)getpid());
|
||||
unlink(dest_file);
|
||||
rmdir("test_temp_confine_tmp/scratch");
|
||||
rmdir(root);
|
||||
mkdir(root, 0755);
|
||||
mkdir("test_temp_confine_tmp/scratch", 0755);
|
||||
mkdir(outside, 0755);
|
||||
|
||||
File* f = file_create("file.txt");
|
||||
EXPECT_NOT_NULL(f);
|
||||
const char* content = "confined temp dir";
|
||||
f->data->data = malloc(strlen(content));
|
||||
EXPECT_NOT_NULL(f->data->data);
|
||||
memcpy(f->data->data, content, strlen(content));
|
||||
f->data->size = strlen(content);
|
||||
|
||||
Config* config = config_create();
|
||||
EXPECT_NOT_NULL(config);
|
||||
config->temp_dir = str_dup(outside);
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, f, config), FILE_SAVE_ERROR);
|
||||
EXPECT_EQ_INT(access(dest_file, F_OK), -1);
|
||||
free(config->temp_dir);
|
||||
config->temp_dir = str_dup("../escape");
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, f, config), FILE_SAVE_ERROR);
|
||||
EXPECT_EQ_INT(access(dest_file, F_OK), -1);
|
||||
free(config->temp_dir);
|
||||
config->temp_dir = str_dup("scratch");
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, f, config), FILE_SAVE_WRITTEN);
|
||||
EXPECT_EQ_INT(access(dest_file, F_OK), 0);
|
||||
|
||||
file_destroy(f);
|
||||
config_delete(config);
|
||||
unlink(dest_file);
|
||||
rmdir("test_temp_confine_tmp/scratch");
|
||||
rmdir(root);
|
||||
rmdir(outside);
|
||||
}
|
||||
|
||||
/* Issue #251: file_save_to_disk_full must distinguish receiver-side skips
|
||||
(--existing/--ignore-existing/--update) from real writes so the sender can
|
||||
decide whether --remove-source-files may unlink its source. */
|
||||
@@ -1883,6 +1933,81 @@ static void test_keep_dirlinks_secure_open() {
|
||||
file_set_keep_dirlinks(false);
|
||||
}
|
||||
|
||||
/* Build an ArrayList of str_dup'd strings (NULL on allocation failure). */
|
||||
static ArrayList* make_manifest_string_list(const char* const* entries, int count) {
|
||||
ArrayList* list = array_list_create(free);
|
||||
if (!list)
|
||||
return NULL;
|
||||
for (int i = 0; i < count; i++) {
|
||||
char* dup = str_dup(entries[i]);
|
||||
if (!dup || !array_list_add(list, dup)) {
|
||||
free(dup);
|
||||
array_list_delete(list);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/* Regression (#3): a non-empty --delete-missing-args directory charges each
|
||||
* removed entry exactly once. The directory itself must not be counted twice;
|
||||
* if it were, `deleted` would exceed --max-delete and the extras walk would
|
||||
* underflow its remaining budget and delete past the user's cap. */
|
||||
static void test_manifest_delete_missing_dir_budget_double_count() {
|
||||
char root[PATH_MAX];
|
||||
snprintf(root, sizeof(root), "/tmp/fastsync_mgdir_%d", (int)getpid());
|
||||
char* gone = path_cat(root, "gone");
|
||||
char* gone_file = path_cat(gone, "f0");
|
||||
char* extra = path_cat(root, "extra.txt");
|
||||
EXPECT_NOT_NULL(gone);
|
||||
EXPECT_NOT_NULL(gone_file);
|
||||
EXPECT_NOT_NULL(extra);
|
||||
mkdir(root, 0755);
|
||||
mkdir(gone, 0755);
|
||||
EXPECT_EQ_INT(access(extra, F_OK), -1);
|
||||
EXPECT_TRUE(file_write_to_disk(extra, "extra", 5, false, false));
|
||||
/* The missing-arg directory holds N-1 == 2 entries; with the directory itself
|
||||
that is exactly --max-delete=3. */
|
||||
EXPECT_TRUE(file_write_to_disk(gone_file, "x", 1, false, false));
|
||||
char* gone_file2 = path_cat(gone, "f1");
|
||||
EXPECT_TRUE(gone_file2 != NULL && file_write_to_disk(gone_file2, "x", 1, false, false));
|
||||
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->receive_root_directory = str_dup(root);
|
||||
cfg->use_delete = true;
|
||||
cfg->delete_missing_args = true;
|
||||
cfg->max_delete = 3;
|
||||
|
||||
const char* missing_names[] = {"gone"};
|
||||
const char* synced[] = {"."};
|
||||
DeleteManifest manifest = {0};
|
||||
manifest.keeps = make_manifest_string_list(NULL, 0);
|
||||
manifest.missing = make_manifest_string_list(missing_names, 1);
|
||||
manifest.dirs = make_manifest_string_list(synced, 1);
|
||||
EXPECT_NOT_NULL(manifest.keeps);
|
||||
EXPECT_NOT_NULL(manifest.missing);
|
||||
EXPECT_NOT_NULL(manifest.dirs);
|
||||
|
||||
DeleteCommitResult result = manifest_delete_all(cfg, &manifest);
|
||||
EXPECT_EQ_INT((int)result, (int)DELETE_COMMIT_LIMIT_REACHED);
|
||||
/* The whole missing-arg directory is gone (dir + its 2 entries == 3). */
|
||||
EXPECT_EQ_INT(access(gone, F_OK), -1);
|
||||
/* The saturated budget must leave the in-scope extra untouched. */
|
||||
EXPECT_EQ_INT(access(extra, F_OK), 0);
|
||||
|
||||
array_list_delete(manifest.keeps);
|
||||
array_list_delete(manifest.missing);
|
||||
array_list_delete(manifest.dirs);
|
||||
config_delete(cfg);
|
||||
unlink(extra);
|
||||
free(gone);
|
||||
free(gone_file);
|
||||
free(gone_file2);
|
||||
free(extra);
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
void test_file() {
|
||||
test_file_create();
|
||||
test_file_special_rdev_valid();
|
||||
@@ -1896,6 +2021,7 @@ void test_file() {
|
||||
test_file_save_to_disk_ignore_existing();
|
||||
test_file_save_to_disk_ignore_existing_entry_types();
|
||||
test_file_save_to_disk_partial_install();
|
||||
test_file_save_to_disk_temp_dir_confined();
|
||||
test_file_save_to_disk_reports_skips();
|
||||
test_file_write_to_disk_sparse_preserves_holes();
|
||||
test_file_write_to_disk_partial_retention();
|
||||
@@ -1936,4 +2062,5 @@ void test_file() {
|
||||
test_inplace_overwrite_truncates_shorter_payload();
|
||||
test_inplace_refuses_fifo_destination();
|
||||
test_inplace_refuses_device_destination();
|
||||
test_manifest_delete_missing_dir_budget_double_count();
|
||||
}
|
||||
|
||||
+13
-3
@@ -522,10 +522,19 @@ static void test_data_create_starts_uncharged_and_unowned() {
|
||||
data_destroy(reserved);
|
||||
}
|
||||
|
||||
/* The server floors a client --timeout=0 at SERVER_IO_TIMEOUT_SEC so a silent
|
||||
* peer can never hold a session slot forever (slow-loris). */
|
||||
static void test_protocol_server_io_timeout_floor() {
|
||||
EXPECT_EQ_INT(protocol_server_io_timeout_sec(0), SERVER_IO_TIMEOUT_SEC);
|
||||
EXPECT_EQ_INT(protocol_server_io_timeout_sec(-7), SERVER_IO_TIMEOUT_SEC);
|
||||
EXPECT_EQ_INT(protocol_server_io_timeout_sec(30), 30);
|
||||
EXPECT_TRUE(SERVER_IO_TIMEOUT_SEC > 0);
|
||||
}
|
||||
|
||||
static void test_protocol_session_io_timeout() {
|
||||
/* Default is the built-in 60 s window; the setter stores exactly what it is
|
||||
* given (<= 0 means "fall back to the default") so callers can propagate
|
||||
* --timeout without special-casing 0. */
|
||||
/* The default is the built-in 60 s window; the setter stores exactly what it
|
||||
* is given (<= 0 disables the deadline, matching rsync's --timeout=0) so
|
||||
* callers can propagate --timeout without special-casing 0. */
|
||||
ProtocolSession session;
|
||||
protocol_session_init(&session, -1, -1);
|
||||
EXPECT_EQ_INT(session.io_timeout_sec, 60);
|
||||
@@ -670,6 +679,7 @@ void test_protocol() {
|
||||
test_send_receive_int();
|
||||
test_send_receive_status();
|
||||
test_protocol_session_io_timeout();
|
||||
test_protocol_server_io_timeout_floor();
|
||||
test_send_receive_status_timed();
|
||||
test_receive_status_keepalive_skips_reply();
|
||||
test_receive_status_keepalive_aborts();
|
||||
|
||||
Reference in New Issue
Block a user