Merge branch 'feat/w9-dryrun' into fix/w9-integration

This commit is contained in:
2026-09-13 13:27:32 +02:00
20 changed files with 987 additions and 62 deletions
+30
View File
@@ -43,6 +43,9 @@ from common import (
_find_free_port,
_wait_for_port,
)
# The dry-run no-mutation contract is asserted with the same structural snapshot
# (mode/inode/mtime/xattr/content) the feature suite uses.
from test_features import _snapshot_tree
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "daemon_source")
MODULE_ROOT = os.path.join(TEST_DATA_DIR, "daemon_modules")
@@ -355,6 +358,33 @@ class TestDaemonRejection:
assert result.returncode != 0
assert self._tree_files() == before, "read-only rejection wrote under the module root"
@pytest.mark.ci
def test_read_only_module_allows_dry_run(self, daemon):
"""A server-contacting --dry-run IS a read-only wire operation, so a
`read only = yes` module is the safest dry-run target and must accept it
while writing nothing."""
result, _ = run_client(SOURCE_DIR, "127.0.0.1::readonly", flags=["--dry-run"],
port=daemon.port)
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
assert "Dry run:" in result.stdout, result.stdout[:200]
assert _tree_file_count(READONLY_MODULE) == 0, "read-only dry-run wrote a file"
@pytest.mark.ci
def test_module_dry_run_mutates_nothing(self, daemon):
"""A daemon-module dry-run reports would-transfer entries but leaves the
module tree structurally identical (mode/inode/mtime/xattr/content)."""
result = _push("127.0.0.1::files", daemon.port)
assert result.returncode == 0, result.stderr or result.stdout
before = _snapshot_tree(FILES_MODULE)
# --ignore-times forces every regular file to be reported as
# would-transfer, so the dry-run exercises the receiver decision rather
# than an all-skip shortcut -- while still mutating nothing.
result, _ = run_client(SOURCE_DIR, "127.0.0.1::files",
flags=["--dry-run", "--ignore-times"], port=daemon.port)
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
assert "Dry run:" in result.stdout, result.stdout[:200]
assert _snapshot_tree(FILES_MODULE) == before, "daemon dry-run mutated the module root"
def test_unknown_module_rejected(self, daemon):
result = _push("127.0.0.1::no-such-module", daemon.port)
assert result.returncode != 0
+347
View File
@@ -370,6 +370,353 @@ class TestDryRun:
assert not mismatches, f"Mismatch: {mismatches}"
def _snapshot_xattrs(path):
"""Return a stable, comparable tuple of (name, value) xattr pairs.
Returns None when the platform/filesystem does not expose xattrs so both
snapshots agree on "unavailable" instead of one being treated as changed."""
try:
names = os.listxattr(path, follow_symlinks=False)
except (AttributeError, OSError):
return None
if not names:
return ()
pairs = []
for name in sorted(names):
try:
value = os.getxattr(path, name, follow_symlinks=False)
except OSError:
value = None
pairs.append((name, value))
return tuple(pairs)
def _snapshot_tree(root):
"""Return a structural snapshot of a directory tree.
Every entry (including directories) is recorded as
(inode, mtime_ns, mode, xattrs, kind-specific payload) so a dry-run that
touched a mode, inode, mtime, xattr, or content is observable. Regular
files carry their size+bytes, symlinks their target, and special entries
(FIFO/socket/device) their size only -- opening a special file could block.
Returns an empty dict for a missing root so "nothing was created" is also
observable."""
snapshot = {}
if not os.path.exists(root):
return snapshot
for dirpath, dirnames, filenames in os.walk(root):
for name in list(dirnames) + filenames:
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, root)
st = os.lstat(path)
entry = [st.st_ino, st.st_mtime_ns, stat.S_IMODE(st.st_mode), _snapshot_xattrs(path)]
if stat.S_ISLNK(st.st_mode):
entry.append(("symlink", os.readlink(path)))
elif stat.S_ISREG(st.st_mode):
with open(path, "rb") as fh:
data = fh.read()
entry += [st.st_size, data]
else:
entry.append(st.st_size)
snapshot[rel] = tuple(entry)
return snapshot
class TestRemoteDryRun:
"""Server-contacting --dry-run (protocol 2.21.0): contacts the receiver,
reports what WOULD transfer/skip based on receiver state, and mutates
nothing on either side."""
def _seed(self, source):
clean_dir(source)
os.makedirs(os.path.join(source, "nested"), exist_ok=True)
with open(os.path.join(source, "keep.txt"), "wb") as f:
f.write(b"unchanged content\n")
with open(os.path.join(source, "changed.txt"), "wb") as f:
f.write(b"original content\n")
with open(os.path.join(source, "nested", "deep.txt"), "wb") as f:
f.write(b"deep file\n")
@pytest.mark.ci
def test_remote_dry_run_reports_changes_and_mutates_nothing(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_dst")
self._seed(source)
clean_dir(dest)
# Populate the destination with a real transfer, then make exactly one
# file differ (content+size) and add a brand-new file.
result, _ = run_client(source, dest, port=shared_server.port)
assert result.returncode == 0, f"seed transfer failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
with open(os.path.join(source, "changed.txt"), "wb") as f:
f.write(b"a much longer replacement payload\n")
with open(os.path.join(source, "added.txt"), "wb") as f:
f.write(b"newly added\n")
before = _snapshot_tree(received)
# --checksum makes the up-to-date decision content-based (the seed
# transfer did not preserve mtimes), so keep.txt/deep.txt report skip.
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum"],
port=shared_server.port)
assert result.returncode == 0, f"remote dry-run failed: {result.stderr[:300]}"
assert "Dry run:" in result.stdout, result.stdout[:200]
assert "changed.txt" in result.stdout, result.stdout
assert "added.txt" in result.stdout, result.stdout
assert "keep.txt" not in result.stdout, (
f"up-to-date file must not be reported as would-transfer: {result.stdout}"
)
assert "deep.txt" not in result.stdout, result.stdout
assert _snapshot_tree(received) == before, "remote dry-run mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_into_empty_dest_creates_nothing(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_empty_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_empty_dst")
self._seed(source)
clean_dir(dest)
received = get_dest_received_dir(dest, source)
assert not os.path.exists(received)
result, _ = run_client(source, dest, flags=["--dry-run"], port=shared_server.port)
assert result.returncode == 0, f"exit {result.returncode}: {result.stderr[:300]}"
assert "keep.txt" in result.stdout
assert "changed.txt" in result.stdout
assert "deep.txt" in result.stdout
# Nowhere may the receiver have created the destination mirror.
assert not os.path.exists(received), "dry-run created directories on the receiver"
assert _snapshot_tree(received) == {}
@pytest.mark.ci
def test_remote_dry_run_mkpath_does_not_create_root(self, shared_server):
"""A wire dry_run cannot make --mkpath create anything, and it cannot
relax the precondition either: a nonexistent root is rejected (a real
run without the created root is impossible in dry-run) while nothing is
created."""
source = os.path.join(TEST_DATA_DIR, "remote_dry_mk_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_mk_dst")
self._seed(source)
shutil.rmtree(dest, ignore_errors=True)
assert not os.path.exists(dest)
result, _ = run_client(source, dest, flags=["--dry-run", "--mkpath"],
port=shared_server.port)
assert result.returncode != 0, "dry-run --mkpath accepted a nonexistent receive root"
assert not os.path.exists(dest), "dry-run --mkpath created the destination root"
@pytest.mark.ci
def test_remote_dry_run_with_delete_does_not_delete(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_del_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_del_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
extra = os.path.join(received, "extra.txt")
with open(extra, "wb") as f:
f.write(b"must survive a dry-run delete\n")
before = _snapshot_tree(received)
for flags in (["--dry-run", "--delete"], ["--dry-run", "--delete-after"]):
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, f"{flags}: {result.stderr[:300]}"
assert os.path.exists(extra), f"{flags} deleted an extra in dry-run"
assert _snapshot_tree(received) == before, f"{flags} mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_quiet_is_silent(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_quiet_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_quiet_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, flags=["-q", "--dry-run"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert result.stdout == ""
assert result.stderr == ""
@pytest.mark.ci
def test_remote_dry_run_threaded_routes_to_server(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_mt_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_mt_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, flags=["--dry-run", "--threads"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert "changed.txt" in result.stdout
assert _snapshot_tree(get_dest_received_dir(dest, source)) == {}
@pytest.mark.ci
def test_normal_transfer_unaffected_by_dry_run(self, shared_server):
"""A real transfer after dry-run still installs the changes."""
source = os.path.join(TEST_DATA_DIR, "remote_dry_normal_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_normal_dst")
self._seed(source)
clean_dir(dest)
run_client(source, dest, port=shared_server.port)
received = get_dest_received_dir(dest, source)
with open(os.path.join(source, "changed.txt"), "wb") as f:
f.write(b"updated payload for the real transfer\n")
run_client(source, dest, flags=["--dry-run"], port=shared_server.port)
result, _ = run_client(source, dest, port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
with open(os.path.join(received, "changed.txt"), "rb") as f:
assert f.read() == b"updated payload for the real transfer\n"
@pytest.mark.ci
def test_remote_dry_run_delay_updates_mutates_nothing(self, shared_server):
"""--delay-updates stages under the receive root; a dry-run must neither
create that staging tree nor publish anything (mode/inode/mtime intact)."""
source = os.path.join(TEST_DATA_DIR, "remote_dry_delay_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_delay_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, flags=["--delay-updates"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
with open(os.path.join(source, "changed.txt"), "wb") as f:
f.write(b"changed for delay-updates dry-run\n")
before = _snapshot_tree(dest)
result, _ = run_client(source, dest, flags=["--dry-run", "--delay-updates"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert "changed.txt" in result.stdout, result.stdout
assert _snapshot_tree(dest) == before, "delay-updates dry-run mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_backup_mutates_nothing(self, shared_server):
"""--backup would rename the old file aside; a dry-run must not."""
source = os.path.join(TEST_DATA_DIR, "remote_dry_backup_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_backup_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
with open(os.path.join(source, "changed.txt"), "wb") as f:
f.write(b"changed for backup dry-run\n")
before = _snapshot_tree(dest)
result, _ = run_client(source, dest, flags=["--dry-run", "--backup"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert "changed.txt" in result.stdout, result.stdout
assert _snapshot_tree(dest) == before, "--backup dry-run mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_symlink_mutates_nothing(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_symlink_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_symlink_dst")
self._seed(source)
os.symlink("changed.txt", os.path.join(source, "link"))
clean_dir(dest)
result, _ = run_client(source, dest, flags=["-a"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
assert os.path.islink(os.path.join(received, "link"))
# Re-point the source link so the entry is genuinely stale, then prove a
# dry-run leaves the destination link target, inode, and mtime untouched.
os.unlink(os.path.join(source, "link"))
os.symlink("keep.txt", os.path.join(source, "link"))
before = _snapshot_tree(dest)
result, _ = run_client(source, dest, flags=["-a", "--dry-run"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert _snapshot_tree(dest) == before, "symlink dry-run mutated the destination"
assert os.readlink(os.path.join(received, "link")) == "changed.txt"
@pytest.mark.ci
def test_remote_dry_run_hardlink_mutates_nothing(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_hardlink_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_hardlink_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "h1.txt"), "wb") as f:
f.write(b"hardlinked payload\n")
os.link(os.path.join(source, "h1.txt"), os.path.join(source, "h2.txt"))
result, _ = run_client(source, dest, flags=["-H"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
assert os.stat(os.path.join(received, "h1.txt")).st_ino == \
os.stat(os.path.join(received, "h2.txt")).st_ino
# Change the shared inode; both names are now stale in the destination.
with open(os.path.join(source, "h1.txt"), "wb") as f:
f.write(b"changed hardlinked payload\n")
before = _snapshot_tree(dest)
result, _ = run_client(source, dest, flags=["-H", "--dry-run"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert _snapshot_tree(dest) == before, "hardlink dry-run mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_fifo_special_mutates_nothing(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_fifo_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_fifo_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "plain.txt"), "wb") as f:
f.write(b"plain\n")
os.mkfifo(os.path.join(source, "existing.fifo"))
result, _ = run_client(source, dest, flags=["--specials"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
assert stat.S_ISFIFO(os.lstat(os.path.join(received, "existing.fifo")).st_mode)
os.mkfifo(os.path.join(source, "new.fifo"))
before = _snapshot_tree(dest)
result, _ = run_client(source, dest, flags=["--specials", "--dry-run"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert not os.path.exists(os.path.join(received, "new.fifo")), \
"dry-run created a FIFO on the receiver"
assert _snapshot_tree(dest) == before, "special-node dry-run mutated the destination"
@pytest.mark.ci
def test_read_batch_with_dry_run_is_refused(self, shared_server):
"""A dry-run of a local batch apply is meaningless (and must not become a
mutation escape hatch): the CLI rejects the combination up front."""
source = os.path.join(TEST_DATA_DIR, "remote_dry_batch_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_batch_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, flags=["--read-batch=/nonexistent.batch", "--dry-run"],
port=shared_server.port)
assert result.returncode != 0, "read-batch + dry-run was accepted"
combined = (result.stderr or "") + (result.stdout or "")
assert "cannot be combined" in combined or "--dry-run" in combined, combined[:300]
@pytest.mark.ci
def test_remote_dry_run_bad_root_fails_like_real_run(self, shared_server):
"""A wire dry_run must not relax the destination-root precondition: a
missing or non-directory root that fails a real run fails a dry-run too,
and the dry-run must not create/replace anything."""
source = os.path.join(TEST_DATA_DIR, "remote_dry_badroot_src")
self._seed(source)
missing = os.path.join(TEST_DATA_DIR, "remote_dry_badroot_missing")
shutil.rmtree(missing, ignore_errors=True)
real, _ = run_client(source, missing, port=shared_server.port)
assert real.returncode != 0, "real run accepted a missing receive root"
assert not os.path.exists(missing), "real run created the missing root"
dry, _ = run_client(source, missing, flags=["--dry-run"], port=shared_server.port)
assert dry.returncode != 0, "dry-run accepted a missing receive root a real run rejects"
assert not os.path.exists(missing), "dry-run created the missing receive root"
fileroot = os.path.join(TEST_DATA_DIR, "remote_dry_badroot_file")
shutil.rmtree(fileroot, ignore_errors=True)
with open(fileroot, "wb") as f:
f.write(b"i am a regular file, not a directory\n")
real, _ = run_client(source, fileroot, port=shared_server.port)
assert real.returncode != 0, "real run accepted a regular-file receive root"
dry, _ = run_client(source, fileroot, flags=["--dry-run"], port=shared_server.port)
assert dry.returncode != 0, "dry-run accepted a regular-file receive root a real run rejects"
with open(fileroot, "rb") as f:
assert f.read() == b"i am a regular file, not a directory\n", \
"dry-run clobbered a regular-file receive root"
class TestRemoveSourceFiles:
def test_removes_only_transferred_regular_files(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remove_source")
+1 -1
View File
@@ -118,7 +118,7 @@ class TestProtocol:
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
_seed_protocol_source(source)
for bad in ("2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
for bad in ("2.20.0", "2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
port=shared_server.port)
assert result.returncode != 0, f"--protocol={bad} should be rejected"
+28
View File
@@ -590,6 +590,9 @@ static void test_parse_args_port_alias() {
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->server_port, 9000);
/* The default port is 8080; the explicit bit is what lets --dry-run tell an
explicit remote target from the default and route to the server. */
EXPECT_TRUE(cfg->server_port_set);
config_delete(cfg);
cfg = config_create();
@@ -597,6 +600,7 @@ static void test_parse_args_port_alias() {
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv_inline, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->server_port, 9001);
EXPECT_TRUE(cfg->server_port_set);
config_delete(cfg);
cfg = config_create();
@@ -604,6 +608,29 @@ static void test_parse_args_port_alias() {
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv_long, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->server_port, 9002);
EXPECT_TRUE(cfg->server_port_set);
config_delete(cfg);
}
/* An explicit --server-host must set its own routing bit (the field itself
* defaults to 127.0.0.1, so a value check cannot distinguish an explicit host
* from the default); --dry-run uses it to route to the server. */
static void test_parse_args_server_host_sets_routing_bit() {
Config* cfg = config_create();
int positional_args[2];
int positional_count = 0;
EXPECT_FALSE(cfg->server_host_set);
char* argv_space[] = {"fastsync", "--server-host", "example.test", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->server_host, "example.test");
EXPECT_TRUE(cfg->server_host_set);
config_delete(cfg);
cfg = config_create();
char* argv_inline[] = {"fastsync", "--server-host=example.test", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv_inline, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->server_host_set);
config_delete(cfg);
}
@@ -3301,6 +3328,7 @@ void test_client_cli() {
test_parse_args_non_numeric_port();
test_parse_args_invalid_server_port();
test_parse_args_port_alias();
test_parse_args_server_host_sets_routing_bit();
test_parse_args_threads();
test_client_abort_flag();
test_parse_args_invalid_compression_level();
+7 -4
View File
@@ -2347,6 +2347,7 @@ static void golden_config_populate(Config* c) {
c->compression_level = 7;
c->chunk_size = 65536;
c->use_sendfile = false;
c->dry_run = true;
c->use_delete = true;
c->use_incremental = true;
c->size_only = false;
@@ -2398,7 +2399,7 @@ static void golden_config_populate(Config* c) {
c->modify_window = 3;
c->compress_choice = str_dup("zstd");
/* "u=rwx,go=rx" is the same 11 bytes as the original "u=rwX,go=rX" (so the
* frame stays 633 bytes) but X is not in FastSync's chmod grammar, and the
* frame stays 637 bytes) but X is not in FastSync's chmod grammar, and the
* receive-side golden validates the frame. */
c->chmod_spec = str_dup("u=rwx,go=rx");
c->skip_compress_set = true;
@@ -2445,9 +2446,11 @@ static void golden_config_populate(Config* c) {
/* The pinned golden frame (protocol 2.21.0). The values below are the only
* thing that ties the generated table to the historical wire format; update
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. */
#define GOLDEN_WIRE_LEN 633
#define GOLDEN_WIRE_HASH 7591559741712449854ULL
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The combined
* 2.21.0 wave appends the serialized dry_run bool to CONFIG_WIRE_CORE_FIELDS
* and keeps the protocol version string at 2.21.0. */
#define GOLDEN_WIRE_LEN 637
#define GOLDEN_WIRE_HASH 13228626061067899189ULL
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
unsigned long long h = 1469598103934665603ULL;
+84
View File
@@ -6,6 +6,7 @@
#include "protocol.h"
#include "test_utils.h"
#include "utils.h"
#include <dirent.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
@@ -376,6 +377,88 @@ static void test_incremental_check_size_mismatch_full_transfer() {
}
}
/* Server-contacting --dry-run: with the wire config's dry_run set, a file that
is NOT up to date makes the receiver answer STATUS_DRY_RUN_TRANSFER and
return immediately; no data body is read and the destination file is left
byte-for-byte unchanged (no temp file, no write, no rename). */
static void test_incremental_check_dry_run_reports_transfer_without_writing() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->dry_run = true;
char* root = make_check_root("dryw");
EXPECT_NOT_NULL(root);
cfg->receive_root_directory = str_dup(root);
write_check_file(root, "file.txt", "0123456789abcdef");
char path[1024];
snprintf(path, sizeof(path), "%s/file.txt", root);
struct stat st;
EXPECT_EQ_INT(stat(path, &st), 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) {
alarm(30);
close(p[1]);
io_set_fds(p[0], p[0]);
bool skipped = false;
bool would_transfer = false;
File* file = receive_incremental_check_ex(p[0], cfg, &skipped, &would_transfer);
bool ok = file == NULL && !skipped && would_transfer;
file_destroy(file);
config_delete(cfg);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
EXPECT_TRUE(send_str(p[1], "file.txt"));
unsigned long long size = (unsigned long long)st.st_size + 1;
long long mtime = (long long)st.st_mtime;
long long mtime_nsec = 0;
#ifdef __linux__
mtime_nsec = (long long)st.st_mtim.tv_nsec;
#endif
EXPECT_TRUE(send_n_data(p[1], &size, sizeof(size)));
EXPECT_TRUE(send_n_data(p[1], &mtime, sizeof(mtime)));
EXPECT_TRUE(send_n_data(p[1], &mtime_nsec, sizeof(mtime_nsec)));
Status s;
EXPECT_TRUE(receive_status(p[1], &s));
EXPECT_EQ_INT(s, STATUS_DRY_RUN_TRANSFER);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(cfg);
/* The destination file must be untouched and no temp sibling may appear. */
char buf[32] = {0};
int fd = open(path, O_RDONLY);
EXPECT_TRUE(fd >= 0);
ssize_t got = read(fd, buf, sizeof(buf) - 1);
EXPECT_EQ_INT((int)got, 16);
EXPECT_EQ_STR(buf, "0123456789abcdef");
close(fd);
DIR* d = opendir(root);
EXPECT_NOT_NULL(d);
int entries = 0;
const struct dirent* e;
while ((e = readdir(d)) != NULL) {
if (strcmp(e->d_name, ".") != 0 && strcmp(e->d_name, "..") != 0)
entries++;
}
closedir(d);
EXPECT_EQ_INT(entries, 1);
unlink(path);
rmdir(root);
free(root);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* Issue #256: when a received delta claims a result above the whole-file cap,
receive_delta_file must mark the operation failed so the caller aborts with
STATUS_ERROR instead of emitting STATUS_NEXT and waiting for a body that
@@ -771,6 +854,7 @@ void test_server() {
test_receive_incremental_check_rejects_invalid_nanoseconds();
test_incremental_check_quick_skip_by_mtime();
test_incremental_check_size_mismatch_full_transfer();
test_incremental_check_dry_run_reports_transfer_without_writing();
test_incremental_check_delta_oversize_reports_failure();
test_late_manifest_abort_frees_keepset();
test_late_manifest_eof_frees_keepset();