feat(dry-run): server-contacting --dry-run (protocol 2.21.0)

--dry-run now handshakes with a remote/daemon receiver and reports what
WOULD transfer/skip based on receiver state, mutating nothing on either
side.

- Serialize Config.dry_run into the wire config frame and append
  STATUS_DRY_RUN_TRANSFER to the status enum (no renumbering); bump
  PROTOCOL_VERSION/CMake VERSION/CHANGELOG/golden wire to 2.21.0.
- Receiver: receive_incremental_check_ex runs the normal read-only
  decision and answers STATUS_OK (skip) or STATUS_DRY_RUN_TRANSFER
  (would transfer) with no basis materialization/append/delta/full
  transfer.  All mutation sites are guarded by !dry_run: file store,
  manifest deletes, --mkpath root creation, --delay-updates staging,
  publication, directory-time application, and outcome acks.
- Client: send_dry_run_remote connects, sends the config, checks each
  regular file and prints the would-transfer set + trailer; no file data
  or delete manifest is sent.  Plain local destinations keep the
  client-side manifest.
This commit is contained in:
2026-09-13 11:56:05 +02:00
parent 72cccaa256
commit 6f974eff19
21 changed files with 632 additions and 46 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ from common import ( # noqa: E402
verify_transfer,
)
PROTOCOL_VERSION = b"2.20.0"
PROTOCOL_VERSION = b"2.21.0"
STATUS_MANIFEST = 5
STATUS_OK = 0
+165
View File
@@ -370,6 +370,171 @@ class TestDryRun:
assert not mismatches, f"Mismatch: {mismatches}"
def _snapshot_tree(root):
"""Return {relpath: (size, mtime_ns, content_bytes)} for a directory tree.
Used to prove a dry-run left the destination byte-for-byte and
timestamp-for-timestamp unchanged. 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 filenames:
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, root)
st = os.lstat(path)
if stat.S_ISLNK(st.st_mode):
snapshot[rel] = ("symlink", os.readlink(path), st.st_mtime_ns)
continue
with open(path, "rb") as fh:
data = fh.read()
snapshot[rel] = (st.st_size, st.st_mtime_ns, data)
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):
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, f"exit {result.returncode}: {result.stderr[:300]}"
assert "changed.txt" in result.stdout
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"
class TestRemoveSourceFiles:
def test_removes_only_transferred_regular_files(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remove_source")
+3 -3
View File
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
class TestProtocol:
@pytest.mark.ci
def test_protocol_current_version_accepted(self, shared_server):
"""--protocol=2.20.0 (the current PROTOCOL_VERSION) is accepted and the
"""--protocol=2.21.0 (the current PROTOCOL_VERSION) is accepted and the
transfer completes normally."""
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
shutil.rmtree(dest, ignore_errors=True)
os.makedirs(dest)
_seed_protocol_source(source)
result, _ = run_client(source, dest, flags=["--protocol=2.20.0"],
result, _ = run_client(source, dest, flags=["--protocol=2.21.0"],
port=shared_server.port)
assert result.returncode == 0, \
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
@@ -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"
+5 -4
View File
@@ -306,7 +306,7 @@ static void test_parse_args_protocol_accept_current() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
"--dest-dir", "/dst", "--protocol=2.20.0"};
"--dest-dir", "/dst", "--protocol=2.21.0"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
@@ -316,7 +316,7 @@ static void test_parse_args_protocol_accept_current() {
cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--protocol", "2.20.0"};
"/dst", "--protocol", "2.21.0"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
@@ -326,8 +326,9 @@ static void test_parse_args_protocol_accept_current() {
/* Any --protocol value other than the current PROTOCOL_VERSION must end in
* failure (parse_args simply stores it; validate_config rejects it up front). */
static void test_parse_args_protocol_rejects_other_versions() {
static const char* const bad_versions[] = {
"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0", "2.18.0", "2.19.0", "216", "31", "abc", ""};
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0",
"2.17.0", "2.18.0", "2.19.0", "2.20.0",
"216", "31", "abc", ""};
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
+6 -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;
@@ -2443,11 +2444,12 @@ static void golden_config_populate(Config* c) {
c->copy_as_gid = 222;
}
/* The pinned golden frame (protocol 2.20.0). The values below are the only
/* 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 9160991280011164139ULL
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.21.0
* bump appends the serialized dry_run bool to CONFIG_WIRE_CORE_FIELDS. */
#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();