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:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user