Merge branch 'feat/parity-network' into feat/rsync-parity

This commit is contained in:
2026-09-15 23:24:21 +02:00
25 changed files with 1095 additions and 222 deletions
+209 -17
View File
@@ -1327,7 +1327,56 @@ class TestChecksumChoice:
)
assert result.returncode != 0, "sha256 must be rejected, not silently ignored"
@pytest.mark.parametrize("algo", ["xxh64", "md5"])
@pytest.mark.ci
def test_checksum_alone_skips_unchanged(self, shared_server):
"""-c alone (no explicit --incremental) must switch the quick-check to a
content digest: an unchanged file whose mtime differs is skipped."""
source = os.path.join(TEST_DATA_DIR, "checksum_alone_src")
dest = os.path.join(TEST_DATA_DIR, "checksum_alone_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "f.txt"), "wb") as fh:
fh.write(b"same content\n")
result, _ = run_client(source, dest, port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = os.path.join(get_dest_received_dir(dest, source), "f.txt")
assert os.path.exists(received)
# Make the destination mtime differ without changing the bytes.
bumped = os.stat(received).st_mtime + 100
os.utime(received, (bumped, bumped))
result, _ = run_client(source, dest, flags=["-c"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
# A skip leaves our bumped mtime in place; a transfer would rewrite it.
assert os.stat(received).st_mtime == pytest.approx(bumped), \
"-c did not skip an unchanged file"
# A same-size, same-mtime content change is still detected.
with open(received, "wb") as fh:
fh.write(b"DIFF content\n")
os.utime(received, (bumped, bumped))
result, _ = run_client(source, dest, flags=["-c"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
with open(received, "rb") as fh:
assert fh.read() == b"same content\n"
@pytest.mark.ci
def test_checksum_choice_md4_single_name_rejected(self, shared_server):
for bad in ("md4", "sha1", "none", "xxh64,md5"):
result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=[f"--checksum-choice={bad}"],
port=shared_server.port)
assert result.returncode != 0, f"{bad} must be rejected"
@pytest.mark.ci
def test_compress_choice_unsupported_rejected(self, shared_server):
for bad in ("lz4", "zlib", "zlibx"):
result, _ = run_client(SOURCE_DIR, DEST_DIR,
flags=[f"--compress-choice={bad}"],
port=shared_server.port)
assert result.returncode != 0, f"{bad} must be rejected"
@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):
clean_dir(DEST_DIR)
@@ -1348,7 +1397,7 @@ class TestChecksumChoice:
# detected (and re-transferred byte-exactly) because the whole-file digest
# differs -- the explicit reason --checksum exists. This exercises the
# sender/receiver digest agreement for a non-default algorithm.
@pytest.mark.parametrize("algo", ["xxh64", "md5"])
@pytest.mark.parametrize("algo", ["xxh64", "xxh3", "xxh128", "md5"])
@pytest.mark.parametrize("mt", [False, True])
def test_changed_same_size_mtime_redetected(self, shared_server, algo, mt):
clean_dir(DEST_DIR)
@@ -2093,6 +2142,8 @@ class TestTempDir:
source = self._make_source("tempdir_src")
dest = os.path.join(TEST_DATA_DIR, "tempdir_dst")
clean_dir(dest)
# rsync requires the temp dir to already exist (it is not created).
os.makedirs(os.path.join(dest, "scratch"), exist_ok=True)
flags = ["--temp-dir=scratch"] + (["--threads"] 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]}"
@@ -2152,26 +2203,167 @@ class TestTempDir:
assert not os.path.exists(os.path.join(dest, "scratch")), \
"--partial-dir wrote through the scratch dir"
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")
def test_temp_dir_must_exist(self, shared_server):
"""rsync does not create the temp dir; a missing one is a clear error."""
source = self._make_source("tempdir_missing_src")
dest = os.path.join(TEST_DATA_DIR, "tempdir_missing_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"],
missing_rel = os.path.join(dest, "no_such_scratch")
assert not os.path.lexists(missing_rel)
result, _ = run_client(source, dest, flags=["--temp-dir=no_such_scratch"],
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"
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)
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],
result, _ = run_client(source, dest, flags=["--temp-dir", missing_abs],
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"
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."""
source = self._make_source("tempdir_abs_src")
dest = os.path.join(TEST_DATA_DIR, "tempdir_abs_dst")
clean_dir(dest)
scratch = os.path.join(TEST_DATA_DIR, "tempdir_abs_scratch")
shutil.rmtree(scratch, ignore_errors=True)
os.makedirs(scratch)
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)
shutil.rmtree(scratch, ignore_errors=True)
class TestTimeoutAndAllocLimits:
"""#295: rsync defaults --timeout=0 (disabled), --contimeout=60, and
--max-alloc=0 (no limit); 0 must be accepted for all three."""
def _seed(self, name):
source = os.path.join(TEST_DATA_DIR, name)
dest = os.path.join(TEST_DATA_DIR, name + "_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "f.txt"), "wb") as fh:
fh.write(b"payload\n" * 100)
return source, dest
@pytest.mark.ci
def test_timeout_zero_disables_and_transfers(self, shared_server):
source, dest = self._seed("timeout_zero_src")
result, _ = run_client(source, dest, flags=["--timeout=0", "--contimeout=0"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not missing and not mismatches
@pytest.mark.ci
def test_no_timeout_forms(self, shared_server):
source, dest = self._seed("timeout_no_src")
result, _ = run_client(source, dest, flags=["--timeout=30", "--no-timeout",
"--no-contimeout"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
@pytest.mark.ci
def test_max_alloc_zero_means_no_limit(self, shared_server):
source, dest = self._seed("max_alloc_zero_src")
result, _ = run_client(source, dest, flags=["--max-alloc=0"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
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
copy instead of aborting (rsync parity). Skipped when no second
filesystem is available."""
shm = "/dev/shm"
if not os.path.isdir(shm):
pytest.skip("/dev/shm not available")
if os.stat(shm).st_dev == os.stat(TEST_DATA_DIR).st_dev:
pytest.skip("/dev/shm is on the same filesystem as the test data")
scratch = os.path.join(shm, f"fastsync_tmp_{os.getpid()}")
shutil.rmtree(scratch, ignore_errors=True)
os.makedirs(scratch)
try:
source, dest = self._seed("tempdir_xdev_src")
result, _ = run_client(source, dest, flags=["--temp-dir", 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"
finally:
shutil.rmtree(scratch, ignore_errors=True)
class TestRemoteOptionTransport:
"""#296: -M/--remote-option is SSH-only; a daemon/TCP destination rejects it
instead of silently ignoring it."""
@pytest.mark.ci
def test_remote_option_rejected_for_tcp(self, shared_server):
for flag in ("--remote-option=--allow-delete", "-M--allow-delete", "-M=--allow-delete"):
result, _ = run_client(SOURCE_DIR, DEST_DIR, flags=[flag],
port=shared_server.port)
assert result.returncode != 0, f"{flag} must be rejected for a TCP destination"
assert "remote-option" in (result.stderr + result.stdout), \
f"{flag}: error must name --remote-option"
class TestTrustSenderServerPath:
"""--trust-sender is a receiver-local policy: only the receiving SERVER's
own flag matters. For a push, a client --trust-sender is never sent to the
peer, so it must not relax a server that did not opt in; a server started
with --trust-sender must copy an escaping symlink target verbatim (its
normal mode skips it while still confining the link itself)."""
def _make_source(self, name):
source = os.path.join(TEST_DATA_DIR, name)
clean_dir(source)
with open(os.path.join(source, "file.txt"), "wb") as fh:
fh.write(b"content\n")
os.symlink("/etc/passwd", os.path.join(source, "escape_link"))
return source
def _run_with_server(self, extra_args, flags, tag):
server = ServerManager()
server.start(extra_args=extra_args)
try:
source = self._make_source(f"trust_sender_src_{tag}")
dest = os.path.join(TEST_DATA_DIR, f"trust_sender_dst_{tag}")
clean_dir(dest)
result, _ = run_client(source, dest, flags=["-l"] + flags, port=server.port)
link = os.path.join(get_dest_received_dir(dest, source), "escape_link")
return result, link
finally:
server.stop()
@pytest.mark.ci
def test_client_flag_does_not_relax_server(self):
result, link = self._run_with_server([], ["--trust-sender"], "client")
assert result.returncode == 0, result.stderr[:200]
assert not os.path.lexists(link), \
"a client --trust-sender must not relax a server that did not opt in"
@pytest.mark.ci
def test_server_flag_materializes_escaping_symlink(self):
result, link = self._run_with_server(["--trust-sender"], [], "server")
assert result.returncode == 0, result.stderr[:200]
assert os.path.islink(link), "server --trust-sender should materialize the symlink"
assert os.readlink(link) == "/etc/passwd"
def _source_files():
+25 -2
View File
@@ -150,13 +150,36 @@ class TestStopAt:
assert _received_files(received) == [], \
f"expected nothing transferred, got {_received_files(received)}"
@pytest.mark.ci
def test_stop_at_rsync_date_form(self, shared_server):
"""rsync's full date form (Y-M-DTh:m) is accepted; a deadline well in the
future lets the transfer complete normally."""
source, dest = _make("dateform")
_seed_source(source)
stamp = time.strftime("%Y-%m-%dT%H:%M", time.localtime(time.time() + 3600))
result, _ = run_client(source, dest, flags=[f"--stop-at={stamp}"],
port=shared_server.port)
assert result.returncode == 0, \
f"--stop-at={stamp} should be accepted: " \
f"{(result.stderr or result.stdout)[:400]}"
received = get_dest_received_dir(dest, source)
mismatches, missing = verify_transfer(source, received)
assert not mismatches and not missing
# The slash-separated date spelling is accepted too.
slash = time.strftime("%Y/%m/%dT%H:%M", time.localtime(time.time() + 3600))
result, _ = run_client(source, dest, flags=[f"--stop-at={slash}"],
port=shared_server.port)
assert result.returncode == 0, f"--stop-at={slash} should be accepted"
@pytest.mark.ci
def test_stop_rejects_garbage(self, shared_server):
"""Malformed --stop-at/--stop-after values are rejected up front."""
source, dest = _make("garbage")
_seed_source(source)
for flag in ("--stop-after=abc", "--stop-at=12:99", "--stop-at=12",
"--stop-at=now+5x", "--stop-at=now-5s"):
for flag in ("--stop-after=abc", "--stop-at=12:99", "--stop-at=1234",
"--stop-at=now+5x", "--stop-at=now-5s",
"--stop-at=2000-13-45", "--stop-at=2030-12-31T23:59:59"):
result, _ = run_client(source, dest, flags=[flag],
port=shared_server.port)
assert result.returncode != 0, f"{flag} should be rejected"
+31 -1
View File
@@ -98,18 +98,47 @@ static void test_checksum_algo_name_mapping() {
EXPECT_EQ_INT(checksum_algo_from_name("XXHASH"), (int)CHECKSUM_ALGO_XXH64);
EXPECT_EQ_INT(checksum_algo_from_name("md5"), (int)CHECKSUM_ALGO_MD5);
EXPECT_EQ_INT(checksum_algo_from_name("MD5"), (int)CHECKSUM_ALGO_MD5);
EXPECT_EQ_INT(checksum_algo_from_name("xxh3"), (int)CHECKSUM_ALGO_XXH3);
EXPECT_EQ_INT(checksum_algo_from_name("XXH3"), (int)CHECKSUM_ALGO_XXH3);
EXPECT_EQ_INT(checksum_algo_from_name("xxh128"), (int)CHECKSUM_ALGO_XXH128);
EXPECT_EQ_INT(checksum_algo_from_name("XXH128"), (int)CHECKSUM_ALGO_XXH128);
/* rsync choices FastSync does not implement are rejected by name. */
EXPECT_TRUE(checksum_algo_from_name("md4") < 0);
EXPECT_TRUE(checksum_algo_from_name("sha1") < 0);
EXPECT_TRUE(checksum_algo_from_name("sha256") < 0);
EXPECT_TRUE(checksum_algo_from_name("crc32") < 0);
EXPECT_TRUE(checksum_algo_from_name("none") < 0);
EXPECT_TRUE(checksum_algo_from_name("xxh3") < 0);
EXPECT_TRUE(checksum_algo_from_name("") < 0);
EXPECT_TRUE(checksum_algo_from_name(NULL) < 0);
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_XXH64));
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_MD5));
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_XXH3));
EXPECT_TRUE(checksum_algo_valid((int)CHECKSUM_ALGO_XXH128));
EXPECT_FALSE(checksum_algo_valid(99));
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_XXH64), "xxh64");
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_MD5), "md5");
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_XXH3), "xxh3");
EXPECT_EQ_STR(checksum_algo_name(CHECKSUM_ALGO_XXH128), "xxh128");
}
/* xxh3 is 8 bytes and seed-aware; xxh128 is 16 bytes and differs from both
* xxh64 and md5 for the same input. */
static void test_checksum_xxh3_xxh128() {
EXPECT_EQ_INT((int)checksum_digest_len(CHECKSUM_ALGO_XXH3), 8);
EXPECT_EQ_INT((int)checksum_digest_len(CHECKSUM_ALGO_XXH128), 16);
uint8_t a[CHECKSUM_MAX_DIGEST_LEN], b[CHECKSUM_MAX_DIGEST_LEN];
size_t alen = 0, blen = 0;
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH3, 0, "payload", 7, a, sizeof(a), &alen));
EXPECT_TRUE(alen == (size_t)8);
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH3, 5, "payload", 7, b, sizeof(b), &blen));
EXPECT_TRUE(memcmp(a, b, alen) != 0);
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH128, 0, "payload", 7, a, sizeof(a), &alen));
EXPECT_TRUE(alen == (size_t)16);
EXPECT_TRUE(checksum_digest(CHECKSUM_ALGO_XXH128, 0, "payload", 7, b, sizeof(b), &blen));
EXPECT_TRUE(memcmp(a, b, blen) == 0);
}
static void test_checksum_truncated_buffer_rejected() {
@@ -142,6 +171,7 @@ void test_checksum(void) {
test_checksum_algo_lengths_distinct();
test_checksum_md5_seed_ignored();
test_checksum_algo_name_mapping();
test_checksum_xxh3_xxh128();
test_checksum_truncated_buffer_rejected();
test_checksum_null_empty_digest();
}
+136 -8
View File
@@ -801,8 +801,11 @@ static void test_parse_args_rejects_invalid_modify_window() {
}
static void test_parse_args_max_alloc_sizes() {
const char* values[] = {"1", "4K", "2m", "3G", "1T", "1P", "1E", "512B"};
const unsigned long long expected[] = {1,
/* "0" is rsync's "no alloc limit" sentinel: it must parse to 0, not be
* rejected. */
const char* values[] = {"0", "1", "4K", "2m", "3G", "1T", "1P", "1E", "512B"};
const unsigned long long expected[] = {0,
1,
4ULL * 1024,
2ULL * 1024 * 1024,
3ULL * 1024 * 1024 * 1024,
@@ -830,8 +833,8 @@ static void test_parse_args_max_alloc_sizes() {
}
static void test_parse_args_rejects_invalid_max_alloc() {
const char* values[] = {"0", "-1", "+1", " 1", "1 ",
"1Z", "1K2", "1 K", "1\tK", "18446744073709551615K"};
const char* values[] = {
"-1", "+1", " 1", "1 ", "1Z", "1K2", "1 K", "1\tK", "18446744073709551615K"};
for (size_t i = 0; i < sizeof(values) / sizeof(values[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--max-alloc", (char*)values[i], "/src", "/dst"};
@@ -1413,7 +1416,8 @@ static void test_parse_args_checksum_choice_equals_forms() {
/* An algorithm FastSync does not support must be rejected, never a silent
no-op. */
static void test_parse_args_checksum_choice_rejects_unsupported() {
static const char* const bad[] = {"md4", "sha256", "crc32", "none", "bogus"};
static const char* const bad[] = {"md4", "sha1", "sha256", "crc32",
"none", "bogus", "xxh64,md5", "xxhash:md5"};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--checksum-choice", (char*)bad[i], "/src", "/dst"};
@@ -1424,6 +1428,123 @@ static void test_parse_args_checksum_choice_rejects_unsupported() {
}
}
/* xxh3/xxh128 are accepted; "auto" keeps the default algorithm. */
static void test_parse_args_checksum_choice_new_algos() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--checksum-choice=xxh3", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_XXH3);
config_delete(cfg);
cfg = config_create();
char* argv2[] = {"fastsync", "--cc=xxh128", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_XXH128);
config_delete(cfg);
cfg = config_create();
char* argv3[] = {"fastsync", "--checksum-choice=auto", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->checksum_algo, (int)CHECKSUM_ALGO_XXH64);
config_delete(cfg);
}
/* -c/--checksum must run the per-file content-check handshake (FastSync's
* --incremental), but unlike --incremental it must NOT auto-preserve -t/-p. */
static void test_parse_args_checksum_implies_incremental_only() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "-c", "/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->checksum);
EXPECT_TRUE(cfg->use_incremental);
EXPECT_FALSE(cfg->preserve_times);
EXPECT_FALSE(cfg->preserve_perms);
config_delete(cfg);
}
/* rsync's --no-whole-file spelling clears -W. */
static void test_parse_args_no_whole_file() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "-W", "--no-whole-file", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->whole_file);
config_delete(cfg);
}
/* --timeout/--contimeout accept 0 (rsync default: disabled) and the
* --no-timeout/--no-contimeout spellings clear them. */
static void test_parse_args_timeout_zero_and_no_forms() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--timeout=0", "--contimeout=0", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->timeout, 0);
EXPECT_EQ_INT(cfg->contimeout, 0);
config_delete(cfg);
cfg = config_create();
char* argv2[] = {"fastsync", "--timeout", "45", "--contimeout", "90", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv2, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->timeout, 45);
EXPECT_EQ_INT(cfg->contimeout, 90);
config_delete(cfg);
cfg = config_create();
char* argv3[] = {"fastsync", "--timeout=30", "--no-timeout", "--no-contimeout", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv3, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->timeout, 0);
EXPECT_EQ_INT(cfg->contimeout, 0);
config_delete(cfg);
}
/* rsync's --compress-choice choices FastSync does not implement are rejected by
* name; zstd/none/auto are accepted. */
static void test_parse_args_compress_choice_parity() {
static const char* const good[] = {"zstd", "none", "auto"};
for (size_t i = 0; i < sizeof(good) / sizeof(good[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--compress-choice", (char*)good[i], "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
config_delete(cfg);
}
static const char* const bad[] = {"lz4", "zlib", "zlibx", "bogus"};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--compress-choice", (char*)bad[i], "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
}
/* -M/--remote-option is SSH-only: a daemon or local TCP destination must reject
* it instead of silently ignoring it. */
static void test_validate_config_remote_option_requires_ssh() {
Config* cfg = valid_client_config();
cfg->remote_options = malloc(sizeof(char*));
cfg->remote_options[0] = str_dup("--allow-delete");
cfg->remote_option_count = 1;
cfg->transport = TRANSPORT_TCP;
EXPECT_FALSE(validate_config(cfg));
cfg->transport = TRANSPORT_SSH;
EXPECT_TRUE(validate_config(cfg));
config_delete(cfg);
}
/* --checksum-seed parses as a 64-bit non-negative integer (space and = forms);
invalid values are rejected. */
static void test_parse_args_checksum_seed() {
@@ -1442,12 +1563,13 @@ static void test_parse_args_checksum_seed() {
EXPECT_TRUE(cfg->checksum_seed == 12345ULL);
config_delete(cfg);
/* 0 is a valid (and default) seed. */
/* An explicit seed of 0 is randomized per transfer (rsync behavior), so the
* parsed config must come back non-zero. */
cfg = config_create();
char* argv3[] = {"fastsync", "--checksum-seed=0", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->checksum_seed == 0ULL);
EXPECT_TRUE(cfg->checksum_seed != 0ULL);
config_delete(cfg);
/* Non-numeric and negative seeds are rejected. */
@@ -1469,7 +1591,7 @@ static void test_parse_args_checksum_seed() {
}
static void test_parse_args_rejects_unsafe_negation() {
static const char* const options[] = {"--no-archive", "--no-timeout", "--no-unknown"};
static const char* const options[] = {"--no-archive", "--no-unknown"};
for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", (char*)options[i], "/src", "/dst"};
@@ -3973,6 +4095,12 @@ void test_client_cli() {
test_parse_args_checksum_choice_requires_value();
test_parse_args_checksum_choice_equals_forms();
test_parse_args_checksum_choice_rejects_unsupported();
test_parse_args_checksum_choice_new_algos();
test_parse_args_checksum_implies_incremental_only();
test_parse_args_no_whole_file();
test_parse_args_timeout_zero_and_no_forms();
test_parse_args_compress_choice_parity();
test_validate_config_remote_option_requires_ssh();
test_parse_args_checksum_seed();
test_parse_args_temp_dir();
test_parse_args_delay_updates();
+15
View File
@@ -64,6 +64,21 @@ static void test_skip_compress_suffix_matching() {
EXPECT_TRUE(compression_should_skip_with_suffixes("backup.TAR.GZ", suffixes, 2));
EXPECT_FALSE(compression_should_skip_with_suffixes("notes.txt", suffixes, 2));
EXPECT_FALSE(compression_should_skip_with_suffixes("archive.zip", suffixes, 0));
/* A user suffix may omit the leading dot (rsync's spelling). */
char* bare[] = {"zip", "gz"};
EXPECT_TRUE(compression_should_skip_with_suffixes("archive.zip", bare, 2));
EXPECT_TRUE(compression_should_skip_with_suffixes("x.GZ", bare, 2));
/* No user list (count < 0) selects rsync 3.4.1's built-in default list. */
EXPECT_TRUE(compression_should_skip_with_suffixes("movie.mp4", NULL, -1));
EXPECT_TRUE(compression_should_skip_with_suffixes("archive.TAR.GZ", NULL, -1));
EXPECT_TRUE(compression_should_skip_with_suffixes("photo.jpeg", NULL, -1));
EXPECT_TRUE(compression_should_skip_with_suffixes("disk.squashfs", NULL, -1));
EXPECT_TRUE(compression_should_skip_with_suffixes("data.7z", NULL, -1));
EXPECT_FALSE(compression_should_skip_with_suffixes("notes.txt", NULL, -1));
EXPECT_FALSE(compression_should_skip_with_suffixes("program", NULL, -1));
EXPECT_FALSE(compression_should_skip_with_suffixes("trailing.", NULL, -1));
}
static void test_data_compress_with_threads_roundtrip() {
+3 -2
View File
@@ -2899,13 +2899,14 @@ static void test_config_wire_receive_bounds() {
/* BOOL: only 0/1 is a legal wire value. */
EXPECT_TRUE(receive_hand_built_frame_rejected(write_frame_with_invalid_bool));
/* RAW_MAXALLOC: zero is rejected before it can become the session ceiling. */
/* RAW_MAXALLOC: zero is rsync's --max-alloc=0 "no limit" and round-trips;
* only the over-ceiling clamp is applied server-side. */
Config* c = config_create();
EXPECT_NOT_NULL(c);
c->send_directory = str_dup("/src");
c->receive_root_directory = str_dup("/dst");
c->max_alloc = 0;
EXPECT_TRUE(roundtrip_config_rejected(c));
EXPECT_FALSE(roundtrip_config_rejected(c));
config_delete(c);
/* STR_MODULE: a name outside [A-Za-z0-9._-] is refused. */
+31
View File
@@ -291,6 +291,35 @@ static void test_max_alloc_allows_configured_buffer() {
protocol_session_unbind();
}
/* max_alloc == 0 is rsync's --max-alloc=0 "no limit": allocations of any size
* are permitted. */
static void test_max_alloc_zero_means_unlimited() {
ProtocolSession session;
protocol_session_init(&session, -1, -1);
protocol_session_set_max_alloc(&session, 0);
protocol_session_bind(&session);
void* first = protocol_alloc(1024 * 1024);
void* second = protocol_alloc(8 * 1024 * 1024);
EXPECT_NOT_NULL(first);
EXPECT_NOT_NULL(second);
free(first);
free(second);
protocol_session_unbind();
}
/* A non-positive session io timeout disables the deadline: the getter reports 0
* (not the built-in 60 s fallback) so callers know to wait indefinitely. */
static void test_protocol_get_io_timeout_zero_disables() {
ProtocolSession session;
protocol_session_init(&session, -1, -1);
protocol_session_bind(&session);
protocol_session_set_io_timeout(&session, 0);
EXPECT_EQ_INT(protocol_get_io_timeout_sec(), 0);
protocol_session_set_io_timeout(&session, 45);
EXPECT_EQ_INT(protocol_get_io_timeout_sec(), 45);
protocol_session_unbind();
}
static void test_max_alloc_is_bound_in_worker_threads() {
enum { WORKER_COUNT = 4 };
ProtocolSession sessions[WORKER_COUNT];
@@ -650,6 +679,8 @@ void test_protocol() {
test_max_alloc_rejects_single_buffer();
test_explicit_session_max_alloc_cannot_be_bypassed();
test_max_alloc_allows_configured_buffer();
test_max_alloc_zero_means_unlimited();
test_protocol_get_io_timeout_zero_disables();
test_max_alloc_is_bound_in_worker_threads();
test_protocol_accounting_is_released_in_worker_threads();
test_protocol_accounting_reservation_is_atomic();
+49 -2
View File
@@ -74,8 +74,6 @@ static void test_stop_at_parse_now_plus() {
static void test_stop_at_parse_invalid() {
time_t now = 1700000000;
time_t deadline = 0;
EXPECT_FALSE(stop_parse_at_time("12", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12:3", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("1234", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12:30:5", now, &deadline));
EXPECT_FALSE(stop_parse_at_time("12:30:5x", now, &deadline));
@@ -100,6 +98,54 @@ static void test_stop_at_parse_invalid() {
EXPECT_FALSE(stop_parse_at_time(NULL, now, &deadline));
}
/* rsync's flexible date form for --stop-at (y-m-dTh:m, with / separators and
* abbreviable fields). */
static void test_stop_at_parse_date_forms() {
time_t now = 1700000000;
time_t deadline = 0;
struct tm t;
EXPECT_TRUE(stop_parse_at_time("2030-12-31T23:59", now, &deadline));
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_year + 1900, 2030);
EXPECT_EQ_INT(t.tm_mon + 1, 12);
EXPECT_EQ_INT(t.tm_mday, 31);
EXPECT_EQ_INT(t.tm_hour, 23);
EXPECT_EQ_INT(t.tm_min, 59);
EXPECT_TRUE(stop_parse_at_time("2030/12/31T23:59", now, &deadline));
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_year + 1900, 2030);
EXPECT_EQ_INT(t.tm_mon + 1, 12);
EXPECT_EQ_INT(t.tm_mday, 31);
EXPECT_TRUE(stop_parse_at_time("2030-12-31", now, &deadline));
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_year + 1900, 2030);
EXPECT_EQ_INT(t.tm_hour, 0);
EXPECT_EQ_INT(t.tm_min, 0);
/* Partial forms resolve to the next matching point in the future. */
EXPECT_TRUE(stop_parse_at_time(":59", now, &deadline));
EXPECT_TRUE(deadline > now);
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_min, 59);
EXPECT_TRUE(stop_parse_at_time("1-30", now, &deadline));
EXPECT_TRUE(deadline > now);
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_mon + 1, 1);
EXPECT_EQ_INT(t.tm_mday, 30);
EXPECT_TRUE(stop_parse_at_time("1", now, &deadline));
EXPECT_TRUE(deadline > now);
EXPECT_NOT_NULL(localtime_r(&deadline, &t));
EXPECT_EQ_INT(t.tm_mday, 1);
/* Seconds are not part of rsync's date form. */
EXPECT_FALSE(stop_parse_at_time("2030-12-31T23:59:59", now, &deadline));
}
static void test_stop_deadline_latency() {
struct timespec now;
EXPECT_EQ_INT(clock_gettime(CLOCK_MONOTONIC, &now), 0);
@@ -145,6 +191,7 @@ void test_stop(void) {
test_stop_after_parse_invalid();
test_stop_at_parse_hhmm();
test_stop_at_parse_now_plus();
test_stop_at_parse_date_forms();
test_stop_at_parse_invalid();
test_stop_deadline_latency();
}
+11 -7
View File
@@ -144,14 +144,18 @@ static void test_client_delete_null() {
client_delete(c);
}
/* Test tcp_set_timeouts with valid values */
/* Test tcp_set_timeouts: a non-positive value disables the timeout (rsync's
* --timeout=0 / --contimeout=0), it is not a "leave unchanged" sentinel. */
static void test_tcp_set_timeouts() {
/* Just verify the function doesn't crash with edge cases */
tcp_set_timeouts(0, 0); /* zero means "don't change" */
tcp_set_timeouts(60, 20); /* normal values */
tcp_set_timeouts(-1, -1); /* negative means "don't change" */
/* If we got here without crashing, the test passes */
EXPECT_TRUE(true);
tcp_set_timeouts(0, 0);
EXPECT_EQ_INT(tcp_get_timeout_sec(), 0);
EXPECT_EQ_INT(tcp_get_contimeout_sec(), 0);
tcp_set_timeouts(60, 20);
EXPECT_EQ_INT(tcp_get_timeout_sec(), 60);
EXPECT_EQ_INT(tcp_get_contimeout_sec(), 20);
tcp_set_timeouts(-1, -1);
EXPECT_EQ_INT(tcp_get_timeout_sec(), 0);
EXPECT_EQ_INT(tcp_get_contimeout_sec(), 0);
}
/* Test client_connect with an invalid host (should fail gracefully) */