fix(delay-updates): unique staging dir and implied --delete-after ordering (#317)

This commit is contained in:
2026-09-24 01:18:13 +02:00
parent f7d5dda93b
commit 96e02f52c0
14 changed files with 429 additions and 104 deletions
+11 -7
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -2626,6 +2626,17 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
bool debug_enabled = verbose || config->debug_level != 0;
set_log_level(config->quiet ? LOG_LEVEL_ERROR
: (debug_enabled ? LOG_LEVEL_DEBUG : LOG_LEVEL_WARNING));
/* rsync parity: --delay-updates implies --delete-after. Every staged file is
published first and only then are extras removed. Normalize onto the
existing delete_after wire bool (no new wire field), overriding any other
explicit timing exactly as rsync does; without --delete there is no
deletion, so no timing is set (and the wire config stays valid). */
if (config->delay_updates && config->use_delete) {
config->delete_before = false;
config->delete_during = false;
config->delete_delay = false;
config->delete_after = true;
}
/* rsync's plain --delete defaults to delete-during (--del): each directory's
extras are removed as that directory is processed, so space is freed
progressively and a tight destination never has to hold the whole old+new
+23 -22
View File
@@ -797,15 +797,27 @@ int receiver_process_pending_ctx(Config* config, int file_descriptor, const Rece
log_message(LOG_LEVEL_ERROR, "Did not receive FINISHED Status");
goto receive_error;
}
/* --delay-updates: publish every staged file BEFORE the deferred delete
commit, matching rsync's --delete-after ordering (all updates land first,
then extras are removed). The single-threaded receiver stores files
synchronously, so every staged file is complete here. The -m receiver
hands both publication and deletion to its caller via
pending_manifest/pending_plans; that caller publishes first, after its disk
writer has drained. */
bool handoff = state.pending_manifest != NULL || state.pending_plans != NULL;
if (!handoff && !config->dry_run && config->delay_updates && config->delay_context) {
if (!delay_updates_publish(config->delay_context, config)) {
send_status(file_descriptor, STATUS_ERROR);
goto fail;
}
}
/* Commit-style (late) deletion: every data frame has been received and the
sender proved the whole tree with STATUS_FINISHED. The single-threaded
receiver stores files synchronously, so everything is on disk here and the
deletion can be committed before the --delay-updates publication in
send_success (the walker skips the staging dir, so staged files are never
treated as extras). The -m receiver passes `pending_manifest` because its
disk writer may still be draining; the caller commits after the writer has
joined so no extra file is removed unless the transfer is known to have
succeeded. */
receiver stores files synchronously, so everything is on disk here (and a
--delay-updates run has already published above). The -m receiver passes
`pending_manifest` because its disk writer may still be draining; the
caller commits after the writer has joined so no extra file is removed
unless the transfer is known to have succeeded. */
if (state.deferred_manifest) {
if (state.pending_manifest) {
*state.pending_manifest = state.deferred_manifest;
@@ -989,21 +1001,10 @@ static bool receiver_send_success_frame(int fd, void* context_pointer) {
nothing to publish and no directory times to stamp. */
if (context->config->dry_run)
return receiver_send_final_success(fd, context->config, &context->outcomes, final_status);
/* --delay-updates: the whole protocol stream (including manifest/delete
handling, which ran inside receiver_process) has succeeded and every
staged file was fully written. Publish them atomically now, before the
success/outcome frame tells a --remove-source-files sender it may delete
its sources. */
if (context->config->delay_updates && context->config->delay_context) {
if (!delay_updates_publish(context->config->delay_context, context->config)) {
send_status(fd, STATUS_ERROR);
return false;
}
}
/* P7 Wave D: every child is now written and the delete / --delay-updates
phases have committed, so it is finally safe to stamp directory times.
This runs after the deferred deletion because receiver_process commits it
before calling this success frame. */
/* P7 Wave D: every child is now written and the --delay-updates publication
(done in receiver_process before the delete commit) plus the deferred
deletion have both committed, so it is finally safe to stamp directory
times. */
dir_metadata_list_apply(&context->dir_times, context->config->receive_root_directory,
context->config);
return receiver_send_final_success(fd, context->config, &context->outcomes, final_status);
+18 -18
View File
@@ -981,12 +981,23 @@ static void server_run_mt_receiver(ServerSession* state) {
thrd_join(writer, &writer_result);
bool transfer_ok = receiver_result == thrd_success && writer_result == thrd_success;
PipelineContextReceiver* context = state->context;
if (transfer_ok && !config->dry_run) {
/* --delay-updates: receive_thread has finished the whole protocol stream
and write_thread has drained its queue, so every staged file is complete.
Publish atomically BEFORE the deferred delete commit, matching rsync's
--delete-after ordering (all updates land first, then extras are
removed). */
if (config->delay_updates && config->delay_context &&
!delay_updates_publish(config->delay_context, config)) {
transfer_ok = false;
}
}
if (transfer_ok && !config->dry_run) {
/* Commit-style (late) deletion: receive_thread handed the keep-set
manifest here instead of deleting while write_thread might still be
draining, so by now every file is on disk and the whole transfer is
known to have succeeded. Remove the extras before publishing a
--delay-updates run; the walker skips the staging directory. A
draining, so by now every file is on disk (and a --delay-updates run has
already published above) and the whole transfer is known to have
succeeded. The walker skips the staging directory. A
server-contacting --dry-run deletes nothing (no manifest is sent). */
if (context->deferred_manifest) {
size_t deleted = 0;
@@ -1026,21 +1037,10 @@ static void server_run_mt_receiver(ServerSession* state) {
delete_plan_session_destroy(context->deferred_plans);
context->deferred_plans = NULL;
}
}
if (transfer_ok && !config->dry_run) {
/* --delay-updates: receive_thread has finished the whole protocol stream
(including manifest/delete handling) and write_thread has drained its
queue, so every staged file is complete. Publish atomically before the
success/outcome frame so a --remove-source-files sender only learns of
files that were actually installed. */
if (config->delay_updates && config->delay_context &&
!delay_updates_publish(config->delay_context, config)) {
transfer_ok = false;
}
/* P7 Wave D: all writers have joined and the late deletion (and
--delay-updates publication) has committed above, so it is finally safe
to stamp directory times; a directory's mtime must not be clobbered by
its children or by an extra removal. */
/* P7 Wave D: all writers have joined and the --delay-updates publication
plus the late deletion have committed above, so it is finally safe to
stamp directory times; a directory's mtime must not be clobbered by its
children or by an extra removal. */
if (transfer_ok)
dir_metadata_list_apply(&context->dir_times, config->receive_root_directory, config);
}
+118 -36
View File
@@ -8,13 +8,49 @@
#include <errno.h>
#include <fcntl.h>
#include <libgen.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/file.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
/* Process-wide counter so two staging contexts created in the same process (or
within the same clock tick) can never pick the same name. */
static unsigned long long delay_updates_next_sequence(void) {
static atomic_ullong sequence;
return atomic_fetch_add_explicit(&sequence, 1, memory_order_relaxed);
}
/* Build the per-run staging directory basename: the reserved prefix plus the
pid and an entropy token. A fixed name could collide with a genuine
destination entry; the token makes such a collision vanishingly unlikely and,
if it ever happens, prepare() refuses to touch the existing directory. */
static char* delay_updates_make_staging_name(void) {
unsigned long long entropy = 0;
int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
if (fd >= 0) {
ssize_t got = read(fd, &entropy, sizeof(entropy));
close(fd);
if (got != (ssize_t)sizeof(entropy))
entropy = 0;
}
if (entropy == 0)
entropy = ((unsigned long long)time(NULL) << 20) ^ ((unsigned long long)getpid() << 8) ^
delay_updates_next_sequence();
int length = snprintf(NULL, 0, DELAY_UPDATES_STAGING_DIR ".%ld.%llx", (long)getpid(), entropy);
if (length < 0)
return NULL;
char* name = malloc((size_t)length + 1);
if (!name)
return NULL;
snprintf(name, (size_t)length + 1, DELAY_UPDATES_STAGING_DIR ".%ld.%llx", (long)getpid(),
entropy);
return name;
}
DelayUpdatesContext* delay_updates_context_create(const char* root_directory) {
if (!root_directory)
return NULL;
@@ -26,8 +62,15 @@ DelayUpdatesContext* delay_updates_context_create(const char* root_directory) {
free(context);
return NULL;
}
context->staging_root = path_cat(root_directory, DELAY_UPDATES_STAGING_DIR);
context->staging_name = delay_updates_make_staging_name();
if (!context->staging_name) {
free(context->root_directory);
free(context);
return NULL;
}
context->staging_root = path_cat(root_directory, context->staging_name);
if (!context->staging_root) {
free(context->staging_name);
free(context->root_directory);
free(context);
return NULL;
@@ -39,6 +82,7 @@ DelayUpdatesContext* delay_updates_context_create(const char* root_directory) {
context->lock_fd = -1;
if (mtx_init(&context->mutex, mtx_plain) != thrd_success) {
free(context->staging_root);
free(context->staging_name);
free(context->root_directory);
free(context);
return NULL;
@@ -54,6 +98,7 @@ void delay_updates_context_destroy(DelayUpdatesContext* context) {
close(context->lock_fd);
context->lock_fd = -1;
free(context->staging_root);
free(context->staging_name);
free(context->root_directory);
for (size_t i = 0; i < context->count; i++) {
free(context->entries[i].staged_path);
@@ -125,48 +170,81 @@ bool delay_updates_prepare(DelayUpdatesContext* context) {
return false;
if (context->prepared)
return true;
int fd = file_open_private_dir(context->staging_root);
if (fd < 0) {
/* Create the per-run staging directory with O_EXCL semantics. The name is
unique to this transfer, so if the path already exists it is NOT ours:
either a genuine destination entry that happens to share the name or a
leftover from another session. Refuse rather than wipe it -- the old
fixed-name design could destroy a real destination entry. A crash
leftover is never reused (the next run picks a fresh name). */
char* leaf = NULL;
int parent_fd = file_open_secure_parent(context->staging_root, &leaf, true);
if (parent_fd < 0) {
int saved_errno = errno;
char* escaped = output_escape(context->staging_root, false);
log_message(LOG_LEVEL_ERROR, "could not create --delay-updates staging directory '%s': %s",
escaped ? escaped : "<allocation failed>", strerror(saved_errno));
free(escaped);
free(leaf);
return false;
}
/* Hold an exclusive advisory lock on the staging directory for the whole
transfer. The staging directory name is fixed, so two simultaneous
delayed transfers to the same destination root would otherwise share it
and destroy each other's staged files. The lock makes the second session
fail cleanly instead of corrupting the first. The lock is released when
the context (and its file descriptor) is destroyed. */
int fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if (fd >= 0) {
close(fd);
close(parent_fd);
char* escaped = output_escape(context->staging_root, false);
log_message(LOG_LEVEL_ERROR,
"--delay-updates staging directory '%s' already exists and is not owned by this "
"transfer; refusing to overwrite it",
escaped ? escaped : "<allocation failed>");
free(escaped);
free(leaf);
return false;
}
if (errno != ENOENT) {
int saved_errno = errno;
close(parent_fd);
char* escaped = output_escape(context->staging_root, false);
log_message(LOG_LEVEL_ERROR, "could not open --delay-updates staging directory '%s': %s",
escaped ? escaped : "<allocation failed>", strerror(saved_errno));
free(escaped);
free(leaf);
return false;
}
if (mkdirat(parent_fd, leaf, 0700) != 0) {
int saved_errno = errno;
close(parent_fd);
char* escaped = output_escape(context->staging_root, false);
log_message(LOG_LEVEL_ERROR, "could not create --delay-updates staging directory '%s': %s",
escaped ? escaped : "<allocation failed>", strerror(saved_errno));
free(escaped);
free(leaf);
return false;
}
fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
close(parent_fd);
free(leaf);
if (fd < 0) {
int saved_errno = errno;
char* escaped = output_escape(context->staging_root, false);
log_message(LOG_LEVEL_ERROR, "could not open --delay-updates staging directory '%s': %s",
escaped ? escaped : "<allocation failed>", strerror(saved_errno));
free(escaped);
return false;
}
/* Keep the exclusive advisory lock as defense in depth: the unique name
already prevents two sessions from sharing a staging directory, but the
lock also catches an improbable same-name collision that raced between the
existence check above and the open. */
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
int saved_errno = errno;
close(fd);
if (saved_errno == EWOULDBLOCK || saved_errno == EAGAIN) {
char* escaped = output_escape(context->staging_root, false);
log_message(LOG_LEVEL_ERROR,
"another --delay-updates transfer to '%s' is already in progress; refusing to "
"share the staging directory",
escaped ? escaped : "<allocation failed>");
free(escaped);
} else {
log_message(LOG_LEVEL_ERROR, "could not lock --delay-updates staging directory '%s': %s",
context->staging_root, strerror(saved_errno));
}
char* escaped = output_escape(context->staging_root, false);
log_message(LOG_LEVEL_ERROR, "could not lock --delay-updates staging directory '%s': %s",
escaped ? escaped : "<allocation failed>", strerror(saved_errno));
free(escaped);
return false;
}
context->lock_fd = fd;
/* Only now, with exclusive ownership, wipe leftovers from an interrupted
earlier transfer; this can never race with a live session. */
bool ok = delay_wipe_dir_fd(fd);
if (!ok) {
log_message(LOG_LEVEL_ERROR, "could not clear stale --delay-updates staging files under '%s'",
context->staging_root);
close(context->lock_fd);
context->lock_fd = -1;
return false;
}
context->prepared = true;
return true;
}
@@ -264,12 +342,16 @@ static bool delay_publish_entry(DelayUpdatesContext* context, const Config* conf
const StagedFileEntry* entry) {
if (!delay_publish_backup(context, config, entry))
return false;
/* --force: an incoming regular file/symlink may replace a destination
DIRECTORY (possibly non-empty). The immediate-install path handles this in
file_receive; a --delay-updates run stages elsewhere and only discovers the
blocking directory here, so clear it before the rename (rsync's
"could not make way for new regular file" without --force). */
if (config && config->force_delete && file_directory_exists_secure(entry->final_path)) {
/* An incoming regular file/symlink may replace a destination DIRECTORY that
blocks it. rsync removes the blocker recursively when --delete or --force
is active (its generator's "make way" deletion), and a --delay-updates run
stages elsewhere so it only discovers the blocker here. FastSync's
immediate-install path clears it too; without --delete/--force a non-empty
blocker fails the run (rsync's "could not make way for new regular file").
use_delete is gated by the server --allow-delete policy, so a client can
never use this to bypass deletion authorization. */
if (config && (config->force_delete || config->use_delete) &&
file_directory_exists_secure(entry->final_path)) {
if (!file_remove_tree_secure(entry->final_path)) {
char* escaped = output_escape(entry->final_path, false);
log_message(LOG_LEVEL_ERROR, "could not remove destination directory blocking '%s': %s",
+6 -1
View File
@@ -24,6 +24,7 @@ typedef struct {
shared with the publish/cleanup phase that runs after the threads join. */
typedef struct DelayUpdatesContext {
char* root_directory; /* receive root the staging dir lives under */
char* staging_name; /* per-run unique staging dir basename */
char* staging_root; /* root_directory/<staging dir name> */
mtx_t mutex;
StagedFileEntry* entries;
@@ -33,7 +34,11 @@ typedef struct DelayUpdatesContext {
int lock_fd; /* advisory exclusive flock held on the staging dir, or -1 */
} DelayUpdatesContext;
/* Name of the private staging subdirectory created under the receive root. */
/* Reserved prefix for the private staging subdirectory created under the
receive root. The actual directory name is per-run unique (the prefix plus a
pid/entropy token) so it can never clobber a genuine destination entry that
happens to share the name; the bare prefix is still what a --backup-dir must
not collide with. */
#define DELAY_UPDATES_STAGING_DIR ".fastsync-stage"
/* True when `dir` (ignoring a trailing "/") is the reserved staging directory
+26 -1
View File
@@ -39,12 +39,31 @@ FilterAction delete_protect_verdict(const DeleteProtectRules* protect, const cha
const char* leaf, bool is_dir) {
if (!protect)
return FILTER_ACTION_NONE;
/* rsync protects its own --backup files from the delete pass: a name ending
in the backup suffix is never an extra. Checked before the filter rules so
an explicit exclude cannot be bypassed (the suffix is always a shield). */
if (protect->backup_suffix && protect->backup_suffix[0] != '\0') {
size_t name_len = strlen(leaf);
size_t suffix_len = strlen(protect->backup_suffix);
if (name_len > suffix_len &&
strcmp(leaf + (name_len - suffix_len), protect->backup_suffix) == 0)
return FILTER_ACTION_PROTECT;
}
FilterAction action = filter_dir_rules_apply_side(protect->dir_rules, rel_path, leaf, is_dir);
if (action != FILTER_ACTION_NONE)
return action;
return filter_rules_apply_side(protect->base_rules, rel_path, leaf, is_dir, FILTER_SIDE_RECEIVER);
}
const char* delete_backup_suffix(const Config* config) {
if (!config || !config->backup || config->ignore_existing)
return NULL;
const char* suffix = config->suffix ? config->suffix : "~";
if (!suffix[0] || strchr(suffix, '/'))
return NULL;
return suffix;
}
/* Classify a removed entry from its st_mode for the per-type delete counters. */
DeleteEntryType delete_entry_type_of_mode(mode_t mode) {
if (S_ISDIR(mode))
@@ -631,7 +650,13 @@ bool delete_skips_build(const Config* config, const ArrayList* protected_paths,
}
int idx = 0;
if (config->delay_updates) {
out->entries[idx].prefix = DELAY_UPDATES_STAGING_DIR;
/* Protect this transfer's actual (per-run unique) staging directory. The
runtime name is only known to the receiver-side context; fall back to the
reserved prefix for a context that was never created (e.g. a dry run). */
const char* staging_name = (config->delay_context && config->delay_context->staging_name)
? config->delay_context->staging_name
: DELAY_UPDATES_STAGING_DIR;
out->entries[idx].prefix = staging_name;
out->entries[idx].top_level_only = true;
idx++;
}
+10
View File
@@ -37,6 +37,11 @@ typedef enum {
typedef struct {
const FilterRuleList* base_rules;
const FilterRuleList* dir_rules;
/* When non-NULL and non-empty, a destination entry whose name ends with this
suffix is protected from deletion. rsync never treats a --backup file as
an extra, so a backup created at --delay-updates publication (or a
pre-existing one) survives the delete-after pass. */
const char* backup_suffix;
} DeleteProtectRules;
/* rsync's first-match-wins receiver verdict for one candidate extra: the
@@ -48,6 +53,11 @@ typedef struct {
FilterAction delete_protect_verdict(const DeleteProtectRules* protect, const char* rel_path,
const char* leaf, bool is_dir);
/* The backup suffix the delete walker must shield from deletion, or NULL when
--backup is inactive or the configured suffix is unusable (empty, or holding
a path separator). Matches the suffix file_save uses for backups. */
const char* delete_backup_suffix(const Config* config);
/* One protected entry for the delete walker. When top_level_only is true the
prefix is skipped only as a DIRECT child of dest_root (the --delay-updates
staging directory, which must not hide genuine extras inside a nested
+4 -2
View File
@@ -170,7 +170,8 @@ static bool delete_extras_budgeted_observed(const Config* config, const DeleteMa
size_t deleted = 0;
size_t skipped = 0;
DeleteProtectRules protect = {.base_rules = config->protect_rules,
.dir_rules = manifest->per_dir_rules};
.dir_rules = manifest->per_dir_rules,
.backup_suffix = delete_backup_suffix(config)};
DeleteWalkResult result = delete_extras_limited_observed(
config->receive_root_directory, manifest->keeps, manifest->dirs, remaining, skips.entries,
skips.count, &protect, &deleted, &skipped, observer, observer_context);
@@ -398,7 +399,8 @@ bool manifest_would_delete_list(const Config* config, const DeleteManifest* mani
if (!delete_skips_build(config, manifest->protected, NULL, true, &skips))
return false;
DeleteProtectRules protect = {.base_rules = config->protect_rules,
.dir_rules = manifest->per_dir_rules};
.dir_rules = manifest->per_dir_rules,
.backup_suffix = delete_backup_suffix(config)};
bool ok = delete_extras_list(config->receive_root_directory, manifest->keeps, manifest->dirs,
skips.entries, skips.count, &protect, out, count_out);
delete_skips_free(&skips);
+1
View File
@@ -801,6 +801,7 @@ static bool build_plan_skips(const Config* config, const DeletePlanSession* sess
PlanSkips* out) {
out->protect.base_rules = config->protect_rules;
out->protect.dir_rules = session->per_dir_rules;
out->protect.backup_suffix = delete_backup_suffix(config);
/* The per-directory plan walk keeps each basis path verbatim (it does not
convert an absolute under-root path to its root-relative form, unlike the
whole-tree commit walk). */
@@ -106,6 +106,15 @@ def seed_backup(_src, rroot, froot):
_mk(os.path.join(root, "a.txt"), b"OLD-CONTENT\n", _OLD_MTIME)
def seed_delay_updates(_src, rroot, froot):
"""A changed file plus an extra, so --delay-updates (and its implied
--delete-after) has both a publication and a deletion to order."""
for root in (rroot, froot):
_mk(os.path.join(root, "a.txt"), b"OLD-CONTENT\n", _OLD_MTIME)
_mk(os.path.join(root, "extra.txt"), b"extra\n", _OLD_MTIME)
_mk(os.path.join(root, "extradir", "z.txt"), b"z\n", _OLD_MTIME)
def seed_size_only(_src, rroot, froot):
for root in (rroot, froot):
_mk(os.path.join(root, "a.txt"), b"XXXXXXXXXXX\n", _OLD_MTIME)
@@ -305,6 +314,14 @@ _CASES = [
H.Case("delete_commit", "basic", ["-a", "--delete-after"], seed=seed_extras,
fastsync_flags=["-a", "--delete-commit"], server_args=DELETE,
ref="FastSync-only --delete-commit == rsync --delete-after"),
# #317: --delay-updates stages under a per-run unique name and publishes
# every update before the implied --delete-after removes extras.
H.Case("delay_updates", "basic", ["-a", "--delay-updates"],
seed=seed_delay_updates, ref="--delay-updates stages then publishes"),
H.Case("delay_updates_delete", "basic",
["-a", "--delay-updates", "--delete"], seed=seed_delay_updates,
server_args=DELETE, ci=True,
ref="--delay-updates implies --delete-after (publish before delete)"),
H.Case("delete_excluded", "filters",
["-a", "--delete", "--delete-excluded", "--exclude=*.log"],
seed=seed_delete_excluded, server_args=DELETE, ref="--delete-excluded"),
+75 -12
View File
@@ -3089,12 +3089,12 @@ class TestDelayUpdates:
"staging directory left behind after a successful delayed transfer"
@pytest.mark.skipif(shutil.which("rsync") is None, reason="rsync not installed")
def test_delay_updates_staging_name_collision_residual(self):
"""Documented residual (RSYNC_COMPAT.md `--delay-updates` row): FastSync
uses a fixed `.fastsync-stage` staging name and wipes a pre-existing tree
of that name at the start of a delayed run (crash-leftover cleanup),
even without `--delete`; rsync leaves a genuine destination entry of that
name untouched. Pins the divergence that keeps the row Divergent."""
def test_delay_updates_staging_name_collision_preserved(self):
"""rsync parity (RSYNC_COMPAT.md `--delay-updates` row): the receiver
stages under a per-run unique name, so a genuine pre-existing
destination entry named like the reserved staging prefix (`.fastsync-
stage`) is never wiped -- even without `--delete`. rsync likewise
leaves a real destination entry of its own temp name untouched."""
source = self._make_source("delay_collide_src")
rdst = os.path.join(TEST_DATA_DIR, "delay_collide_rdst")
fdst = os.path.join(TEST_DATA_DIR, "delay_collide_fdst")
@@ -3120,8 +3120,11 @@ class TestDelayUpdates:
result, _ = run_client(source, fdst, flags=["--delay-updates"],
port=server.port)
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
assert not os.path.exists(os.path.join(fdst, self.STAGING)), \
"FastSync did not wipe the reserved staging name (residual changed)"
assert _read_file(os.path.join(fdst, self.STAGING, "keepme.txt")) == b"genuine user data\n", \
"FastSync destroyed a genuine destination entry named like the staging prefix"
# The per-run staging directory itself is removed after a clean run.
leftovers = [n for n in os.listdir(fdst) if n.startswith(self.STAGING + ".")]
assert leftovers == [], f"per-run staging directories left behind: {leftovers}"
@pytest.mark.parametrize("mt", [False, True])
def test_delay_updates_incremental_rerun_no_leftovers(self, shared_server, mt):
@@ -3161,10 +3164,11 @@ class TestDelayUpdates:
@pytest.mark.parametrize("mt", [False, True])
def test_delete_with_delay_updates(self, mt):
"""--delete runs before publication, so the delete walker must not treat
the staging directory as a set of extras: a changed file must still be
published after genuine extras are removed. Uses its own server started
with --allow-delete (the shared session server refuses deletion)."""
"""rsync parity: --delay-updates implies --delete-after, so every staged
update is published first and the genuine extras are removed only after
that (the delete walker must never treat the staging directory as a set
of extras). Uses its own server started with --allow-delete (the shared
session server refuses deletion)."""
source = os.path.join(TEST_DATA_DIR, "delay_delete_src")
dest = os.path.join(TEST_DATA_DIR, "delay_delete_dst")
clean_dir(source)
@@ -3194,6 +3198,65 @@ class TestDelayUpdates:
assert not os.path.exists(os.path.join(received, "extra.txt")), \
"genuine extra file was not deleted"
assert not os.path.isdir(os.path.join(dest, self.STAGING))
assert [n for n in os.listdir(dest) if n.startswith(self.STAGING + ".")] == []
@pytest.mark.parametrize("mt", [False, True])
def test_delay_updates_delete_keeps_backup(self, mt):
"""The --backup/--delay-updates interplay: the old destination file is
moved aside at publication, and that backup survives the implied
--delete-after pass (rsync never treats a backup file as an extra)."""
source = os.path.join(TEST_DATA_DIR, "delay_bak_del_src")
dest = os.path.join(TEST_DATA_DIR, "delay_bak_del_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "f.txt"), "wb") as fh:
fh.write(b"NEW")
received = get_dest_received_dir(dest, source)
os.makedirs(received, exist_ok=True)
with open(os.path.join(received, "f.txt"), "wb") as fh:
fh.write(b"OLD")
os.utime(os.path.join(received, "f.txt"), (1_500_000_000, 1_500_000_000))
# A pre-existing backup-looking extra must also be shielded.
with open(os.path.join(received, "stale.txt~"), "wb") as fh:
fh.write(b"stale backup")
with ServerManager() as server:
server.start(extra_args=["--allow-delete"])
flags = ["--delete", "--backup", "--delay-updates"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=server.port)
assert result.returncode == 0, (result.stderr or result.stdout)[:300]
assert _read_file(os.path.join(received, "f.txt")) == b"NEW"
assert _read_file(os.path.join(received, "f.txt~")) == b"OLD", \
"the publication backup was removed by the delete-after pass"
assert os.path.exists(os.path.join(received, "stale.txt~")), \
"a pre-existing backup-suffixed entry was deleted"
@pytest.mark.parametrize("mt", [False, True])
def test_delay_updates_failed_run_leaves_no_staged_files(self, shared_server, mt):
"""A run that fails before publication installs nothing and removes the
per-run staging directory (no staged leftovers)."""
source = os.path.join(TEST_DATA_DIR, "delay_fail_src")
dest = os.path.join(TEST_DATA_DIR, "delay_fail_dst")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "top.txt"), "wb") as fh:
fh.write(b"top\n")
os.makedirs(os.path.join(source, "sub"))
with open(os.path.join(source, "sub", "deep.txt"), "wb") as fh:
fh.write(b"deep\n")
# Plant a regular file where the "sub" directory must be created so the
# nested publish fails (the top-level file still publishes first).
received = get_dest_received_dir(dest, source)
os.makedirs(received)
with open(os.path.join(received, "sub"), "wb") as fh:
fh.write(b"blocker")
flags = ["--delay-updates"] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode != 0, "a blocked nested publish must fail the run"
assert not os.path.lexists(os.path.join(received, "sub", "deep.txt")), \
"a staged file appeared despite the failed run"
assert [n for n in os.listdir(dest) if n.startswith(self.STAGING + ".")] == [], \
"the per-run staging directory survived a failed run"
def test_delay_updates_rejects_reserved_backup_dir(self):
"""--backup-dir equal to the internal staging name must be rejected so
+43
View File
@@ -2977,6 +2977,48 @@ static void test_parse_args_delay_updates() {
config_delete(cfg);
}
/* rsync parity: --delay-updates implies --delete-after when --delete is
active (all updates publish first, then extras are removed). An explicit
other timing is overridden; without --delete no timing is set. */
static void test_parse_args_delay_updates_implies_delete_after() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--delay-updates", "--delete", "/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->use_delete);
EXPECT_TRUE(cfg->delete_after);
EXPECT_FALSE(cfg->delete_before);
EXPECT_FALSE(cfg->delete_during);
EXPECT_FALSE(cfg->delete_delay);
cfg->send_directory = str_dup("/src");
cfg->receive_root_directory = str_dup("/dst");
EXPECT_TRUE(validate_config(cfg));
config_delete(cfg);
/* An explicit conflicting timing is normalized to delete-after. */
cfg = config_create();
char* argv_before[] = {"fastsync", "--delay-updates", "--delete-before", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv_before, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->use_delete);
EXPECT_TRUE(cfg->delete_after);
EXPECT_FALSE(cfg->delete_before);
config_delete(cfg);
/* Without --delete there is no deletion, so no timing is selected. */
cfg = config_create();
char* argv_alone[] = {"fastsync", "--delay-updates", "/src", "/dst"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv_alone, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->use_delete);
EXPECT_FALSE(cfg->delete_after);
EXPECT_FALSE(cfg->delete_before);
EXPECT_FALSE(cfg->delete_during);
EXPECT_FALSE(cfg->delete_delay);
config_delete(cfg);
}
/* rsync rejects --delay-updates with --inplace; FastSync must too. */
static void test_validate_config_delay_updates_rejects_inplace() {
Config* cfg = valid_client_config();
@@ -5312,6 +5354,7 @@ void test_client_cli() {
test_parse_args_checksum_seed();
test_parse_args_temp_dir();
test_parse_args_delay_updates();
test_parse_args_delay_updates_implies_delete_after();
test_validate_config_delay_updates_rejects_inplace();
test_validate_config_delay_updates_rejects_reserved_backup_dir();
test_parse_args_files_from();
+66 -5
View File
@@ -87,8 +87,10 @@ static void test_delay_updates_no_final_before_publish() {
const char* final_path = "test_delay_tmp/sub/file.txt";
/* Before publication the final destination must not contain the file. */
EXPECT_FALSE(file_path_exists_secure(final_path));
/* The complete staged copy must live inside the staging tree. */
char* staged = path_cat("test_delay_tmp/.fastsync-stage", "/sub/file.txt");
/* The complete staged copy must live inside the per-run staging tree. */
EXPECT_NOT_NULL(cfg->delay_context->staging_name);
EXPECT_EQ_INT(strncmp(cfg->delay_context->staging_name, ".fastsync-stage.", 16), 0);
char* staged = path_cat(cfg->delay_context->staging_root, "/sub/file.txt");
EXPECT_NOT_NULL(staged);
// cppcheck-suppress knownConditionTrueFalse
if (staged) {
@@ -125,6 +127,8 @@ static void test_delay_updates_publish_installs_files() {
const char* final_path = "test_delay_pub_tmp/sub/file.txt";
EXPECT_FALSE(file_path_exists_secure(final_path));
char* staging_root = str_dup(cfg->delay_context->staging_root);
EXPECT_NOT_NULL(staging_root);
EXPECT_TRUE(delay_updates_publish(cfg->delay_context, cfg));
/* After a successful publish the file is installed and staging is gone. */
char* content = read_all(final_path);
@@ -134,7 +138,8 @@ static void test_delay_updates_publish_installs_files() {
EXPECT_EQ_STR(content, "published payload");
free(content);
}
EXPECT_FALSE(file_path_exists_secure("test_delay_pub_tmp/.fastsync-stage"));
EXPECT_FALSE(file_path_exists_secure(staging_root));
free(staging_root);
out:
file_destroy(f);
@@ -158,11 +163,17 @@ static void test_delay_updates_cleanup_removes_staged() {
goto out;
EXPECT_EQ_INT(file_save_to_disk_full(root, f, cfg), FILE_SAVE_WRITTEN);
EXPECT_TRUE(file_path_exists_secure("test_delay_clean_tmp/.fastsync-stage/sub/file.txt"));
char* staged_file = path_cat(cfg->delay_context->staging_root, "/sub/file.txt");
char* staging_root = str_dup(cfg->delay_context->staging_root);
EXPECT_NOT_NULL(staged_file);
EXPECT_NOT_NULL(staging_root);
EXPECT_TRUE(file_path_exists_secure(staged_file));
delay_updates_cleanup(cfg->delay_context);
EXPECT_FALSE(file_path_exists_secure("test_delay_clean_tmp/.fastsync-stage"));
EXPECT_FALSE(file_path_exists_secure(staging_root));
EXPECT_FALSE(file_path_exists_secure("test_delay_clean_tmp/sub/file.txt"));
free(staged_file);
free(staging_root);
out:
file_destroy(f);
@@ -274,6 +285,54 @@ out:
remove_tree(root);
}
/* Every context picks its own staging directory name, so two delayed
transfers to the same root can never share (and corrupt) a staging tree. */
static void test_delay_updates_unique_staging_name() {
DelayUpdatesContext* first = delay_updates_context_create("test_delay_uniq_tmp");
DelayUpdatesContext* second = delay_updates_context_create("test_delay_uniq_tmp");
EXPECT_NOT_NULL(first);
EXPECT_NOT_NULL(second);
if (first && second) {
EXPECT_EQ_INT(strncmp(first->staging_name, ".fastsync-stage.", 16), 0);
EXPECT_EQ_INT(strncmp(second->staging_name, ".fastsync-stage.", 16), 0);
EXPECT_TRUE(strcmp(first->staging_name, second->staging_name) != 0);
EXPECT_TRUE(strcmp(first->staging_root, second->staging_root) != 0);
}
delay_updates_context_destroy(first);
delay_updates_context_destroy(second);
}
/* A pre-existing destination entry at the exact (random) staging path is not
ours: prepare() must refuse rather than wipe it. */
static void test_delay_updates_prepare_refuses_non_owned_collision() {
const char* root = "test_delay_collide_tmp";
remove_tree(root);
DelayUpdatesContext* context = delay_updates_context_create(root);
EXPECT_NOT_NULL(context);
// cppcheck-suppress knownConditionTrueFalse
if (!context)
return;
/* Plant a genuine directory with user data at the exact staging path. */
EXPECT_TRUE(file_ensure_directory_secure(context->staging_root));
char* inner = path_cat(context->staging_root, "keepme.txt");
EXPECT_NOT_NULL(inner);
// cppcheck-suppress knownConditionTrueFalse
if (inner) {
EXPECT_TRUE(file_write_to_disk(inner, "genuine", 7, false, false));
EXPECT_FALSE(delay_updates_prepare(context));
char* content = read_all(inner);
EXPECT_NOT_NULL(content);
// cppcheck-suppress knownConditionTrueFalse
if (content) {
EXPECT_EQ_STR(content, "genuine");
free(content);
}
free(inner);
}
delay_updates_context_destroy(context);
remove_tree(root);
}
/* The reserved staging name must be recognizable for validation, including
with a trailing slash. */
static void test_delay_updates_reserved_name_helper() {
@@ -288,6 +347,8 @@ static void test_delay_updates_reserved_name_helper() {
void test_delay_updates() {
test_delay_updates_reserved_name_helper();
test_delay_updates_unique_staging_name();
test_delay_updates_prepare_refuses_non_owned_collision();
test_delay_updates_no_final_before_publish();
test_delay_updates_publish_installs_files();
test_delay_updates_cleanup_removes_staged();