fix: --delay-updates delete/backup collisions, publish-failure test, staging lock
Review fixes for --delay-updates: - --delete no longer deletes the staged files: the delete walker gains a skip_root_child parameter and receive_manifest passes DELAY_UPDATES_STAGING_DIR when delay_updates is active, so deletion removes genuine extras while the staging dir (a direct child of the receive root) is left for publication in both single and -m modes. - --backup-dir is rejected when it collides with the reserved internal staging name .fastsync-stage (trailing slash normalized), in client validation and in the received-config wire validation, preventing old backups from being silently installed as new files. - Staging dir is now held under an exclusive advisory flock for the whole transfer (context lifetime): two simultaneous delayed transfers to one destination root no longer share/destroy each other's staged data - the second fails cleanly. Cleanup only touches the staging dir when this context owns the lock, so a lock-contention failure cannot wipe a live session. - Post-publish staging cleanup now returns/logs instead of discarding failures (warning when the staging dir cannot be fully removed). - Reworked the publish-failure integration test to exercise real mid-publish semantics (top-level file published, nested rename fails, no rollback, sources retained under --remove-source-files) and added integration tests for --delete + --delay-updates ordering and reserved --backup-dir rejection. - RSYNC_COMPAT note documents delete ordering, the reserved-name hazard, and the concurrency guard.
This commit is contained in:
+1
-1
@@ -96,7 +96,7 @@ This document maps rsync's full feature set to FastSync's current implementation
|
||||
| `-b`, `--backup` | Make backups of overwritten files | ✅ Implemented | Backup before overwrite |
|
||||
| `--backup-dir=DIR` | Backup directory hierarchy | ✅ Implemented | `backup_dir` config field |
|
||||
| `--suffix=SUFFIX` | Backup suffix (default ~) | ✅ Implemented | `suffix` config field |
|
||||
| `--delay-updates` | Put updated files in place at end | ✅ Implemented | Successfully received files are staged under a private 0700 `.fastsync-stage` dir inside the receive root and atomically renamed into their final destinations only after the whole transfer (manifest/delete handling included) succeeds, just before the success/outcome frame is sent. `--existing`/`--ignore-existing`/`--update` decide against the final destination path at stage time; `--backup` moves the old file aside at publication. Incompatible with `--inplace` (rejected). Aborting or failing before publication installs nothing and removes the staging tree; a crash between stage and publish leaves staged leftovers that the next delayed run wipes at start. A stage→publish failure aborts the transfer (best-effort cleanup of the not-yet-published staged files; already-published files are not rolled back). Works in single-threaded and `-m` modes |
|
||||
| `--delay-updates` | Put updated files in place at end | ✅ Implemented | Successfully received files are staged under a private 0700 `.fastsync-stage` dir inside the receive root and atomically renamed into their final destinations only after the whole transfer (manifest/delete handling included) succeeds, just before the success/outcome frame is sent. The delete walker deliberately skips the staging dir at the receive root, so `--delete` removes genuine extras but never the staged files (deletion runs before publication; rsync's delete-after ordering is not implemented). `--existing`/`--ignore-existing`/`--update` decide against the final destination path at stage time; `--backup` moves the old file aside at publication. Incompatible with `--inplace` and with `--backup-dir=.fastsync-stage` (the internal staging name is reserved; both are rejected). The staging dir name is fixed, so two simultaneous delayed transfers to the same destination root are serialized with an exclusive advisory lock held for the whole transfer: the second session fails cleanly instead of corrupting the first. Aborting or failing before publication installs nothing and removes the staging tree; a crash between stage and publish leaves staged leftovers that the next delayed run wipes at start (process death releases the lock). A stage→publish failure aborts the transfer (best-effort cleanup of the not-yet-published staged files; already-published files are not rolled back). Works in single-threaded and `-m` modes |
|
||||
| `-T`, `--temp-dir=DIR` | Create temporary files in DIR | ✅ Implemented | `--temp-dir` only; `-T` stays FastSync's `--timeout` alias. Scratch dir is resolved under the receive root; temp copies use a unique name there and are atomically renamed into place. If the scratch dir and destination are on different filesystems the atomic rename fails with EXDEV and the file save fails, which aborts the whole transfer (FastSync has no per-file skip/resume on a save error; rsync's non-atomic copy fallback is deliberately not used). `--inplace` and `--partial-dir` writes bypass the scratch dir |
|
||||
|
||||
## 7. Deletion
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "client_validation.h"
|
||||
#include "delay_updates.h"
|
||||
#include "log.h"
|
||||
#include "usage.h"
|
||||
#include <stdio.h>
|
||||
@@ -64,5 +65,11 @@ bool validate_config(const Config* config) {
|
||||
log_message(LOG_LEVEL_ERROR, "--delay-updates does not work with --inplace");
|
||||
return false;
|
||||
}
|
||||
if (config->delay_updates && delay_updates_staging_name_conflict(config->backup_dir)) {
|
||||
log_message(LOG_LEVEL_ERROR,
|
||||
"--backup-dir is reserved when --delay-updates is active (used for the internal "
|
||||
"staging directory)");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ static bool validate_received_config(const Config* config) {
|
||||
valid_wire_bool(config->delete_excluded) && valid_wire_bool(config->delete_after) &&
|
||||
valid_wire_bool(config->relative) && valid_wire_bool(config->prune_empty_dirs) &&
|
||||
valid_wire_bool(config->delay_updates) && !(config->delay_updates && config->inplace) &&
|
||||
!(config->delay_updates && delay_updates_staging_name_conflict(config->backup_dir)) &&
|
||||
valid_wire_bool(config->partial) && valid_wire_bool(config->delete_before) &&
|
||||
valid_wire_bool(config->checksum) && valid_wire_bool(config->eight_bit_output) &&
|
||||
!(config->skip_compress_set && config->use_chunk_serialization) &&
|
||||
|
||||
+73
-14
@@ -11,6 +11,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
@@ -35,6 +36,7 @@ DelayUpdatesContext* delay_updates_context_create(const char* root_directory) {
|
||||
context->count = 0;
|
||||
context->capacity = 0;
|
||||
context->prepared = false;
|
||||
context->lock_fd = -1;
|
||||
if (mtx_init(&context->mutex, mtx_plain) != thrd_success) {
|
||||
free(context->staging_root);
|
||||
free(context->root_directory);
|
||||
@@ -48,6 +50,9 @@ void delay_updates_context_destroy(DelayUpdatesContext* context) {
|
||||
if (!context)
|
||||
return;
|
||||
mtx_destroy(&context->mutex);
|
||||
if (context->lock_fd >= 0)
|
||||
close(context->lock_fd);
|
||||
context->lock_fd = -1;
|
||||
free(context->staging_root);
|
||||
free(context->root_directory);
|
||||
for (size_t i = 0; i < context->count; i++) {
|
||||
@@ -59,6 +64,18 @@ void delay_updates_context_destroy(DelayUpdatesContext* context) {
|
||||
free(context);
|
||||
}
|
||||
|
||||
bool delay_updates_staging_name_conflict(const char* dir) {
|
||||
if (!dir || !*dir)
|
||||
return false;
|
||||
size_t length = strlen(dir);
|
||||
while (length > 0 && dir[length - 1] == '/')
|
||||
length--;
|
||||
size_t reserved_length = strlen(DELAY_UPDATES_STAGING_DIR);
|
||||
if (length != reserved_length)
|
||||
return false;
|
||||
return strncmp(dir, DELAY_UPDATES_STAGING_DIR, length) == 0;
|
||||
}
|
||||
|
||||
/* Recursively delete every entry inside an open directory (never following
|
||||
symlinks). The directory itself is left in place. Mirrors the fd-relative
|
||||
walk used by the delete code so a symlink planted inside the staging tree
|
||||
@@ -117,12 +134,37 @@ bool delay_updates_prepare(DelayUpdatesContext* context) {
|
||||
free(escaped);
|
||||
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. */
|
||||
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));
|
||||
}
|
||||
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 (close(fd) != 0)
|
||||
ok = false;
|
||||
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;
|
||||
@@ -176,7 +218,7 @@ bool delay_updates_record(DelayUpdatesContext* context, const char* staged_path,
|
||||
/* Move an existing final destination file aside before the staged replacement
|
||||
is installed. Deferred from stage time so the final destination is not
|
||||
modified until publication. Mirrors the immediate-mode backup logic. */
|
||||
static bool delay_publish_backup(DelayUpdatesContext* context, const Config* config,
|
||||
static bool delay_publish_backup(const DelayUpdatesContext* context, const Config* config,
|
||||
const StagedFileEntry* entry) {
|
||||
bool backup_enabled = config && config->backup && !config->ignore_existing;
|
||||
if (!backup_enabled)
|
||||
@@ -241,6 +283,20 @@ static bool delay_publish_entry(DelayUpdatesContext* context, const Config* conf
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Remove the staging tree (contents plus the directory itself). Returns true
|
||||
when nothing is left behind (including the case where it never existed). */
|
||||
static bool delay_updates_remove_staging_tree(DelayUpdatesContext* context) {
|
||||
int fd = open(context->staging_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (fd < 0)
|
||||
return errno == ENOENT;
|
||||
bool ok = delay_wipe_dir_fd(fd);
|
||||
if (close(fd) != 0)
|
||||
ok = false;
|
||||
if (ok && rmdir(context->staging_root) != 0 && errno != ENOENT)
|
||||
ok = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool delay_updates_publish(DelayUpdatesContext* context, const Config* config) {
|
||||
if (!context)
|
||||
return false;
|
||||
@@ -257,12 +313,14 @@ bool delay_updates_publish(DelayUpdatesContext* context, const Config* config) {
|
||||
/* Renaming files out of the staging tree leaves the mirrored directories
|
||||
behind, and a mid-publish failure leaves the remaining staged files.
|
||||
Remove whatever is left so a later run starts from a clean staging area
|
||||
and no staged content can linger after a failed publish. */
|
||||
int fd = open(context->staging_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (fd >= 0) {
|
||||
delay_wipe_dir_fd(fd);
|
||||
close(fd);
|
||||
rmdir(context->staging_root);
|
||||
and no staged content can linger after a failed publish. If that cleanup
|
||||
fails, tell the operator: a stale staging directory would otherwise
|
||||
silently accumulate and make the next transfer's prepare-wipe fail. */
|
||||
if (!delay_updates_remove_staging_tree(context)) {
|
||||
log_message(LOG_LEVEL_WARNING,
|
||||
"could not fully remove --delay-updates staging directory '%s' after publish; a "
|
||||
"later --delay-updates transfer to this destination will try to clear it",
|
||||
context->staging_root);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
@@ -270,10 +328,11 @@ bool delay_updates_publish(DelayUpdatesContext* context, const Config* config) {
|
||||
void delay_updates_cleanup(DelayUpdatesContext* context) {
|
||||
if (!context)
|
||||
return;
|
||||
int fd = open(context->staging_root, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
if (fd < 0)
|
||||
/* Only a context that gained exclusive ownership may touch the shared
|
||||
staging directory. If prepare never succeeded (e.g. lock contention with
|
||||
another live session) the directory belongs to that other session and must
|
||||
be left alone. */
|
||||
if (!context->prepared)
|
||||
return;
|
||||
delay_wipe_dir_fd(fd);
|
||||
close(fd);
|
||||
rmdir(context->staging_root);
|
||||
delay_updates_remove_staging_tree(context);
|
||||
}
|
||||
|
||||
@@ -28,12 +28,18 @@ typedef struct DelayUpdatesContext {
|
||||
StagedFileEntry* entries;
|
||||
size_t count;
|
||||
size_t capacity;
|
||||
bool prepared; /* staging dir created and stale leftovers wiped once */
|
||||
bool prepared; /* staging dir created, wiped, and exclusively locked */
|
||||
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. */
|
||||
#define DELAY_UPDATES_STAGING_DIR ".fastsync-stage"
|
||||
|
||||
/* True when `dir` (ignoring a trailing "/") is the reserved staging directory
|
||||
name. Used to reject a --backup-dir that would collide with the internal
|
||||
staging area. */
|
||||
bool delay_updates_staging_name_conflict(const char* dir);
|
||||
|
||||
/* Create an empty staging context rooted below root_directory. Does not touch
|
||||
the filesystem yet. */
|
||||
DelayUpdatesContext* delay_updates_context_create(const char* root_directory);
|
||||
|
||||
+18
-12
@@ -38,20 +38,21 @@ bool file_save_to_disk(const char* root_directory, const File* file, const Confi
|
||||
static FileSaveResult file_stage_delayed_update(const char* root_directory,
|
||||
const char* destination_path, const File* file,
|
||||
Config* config) {
|
||||
bool sparse = config && config->preserve_sparse;
|
||||
bool preserve_executability = config && config->use_executability;
|
||||
if (!config)
|
||||
return FILE_SAVE_ERROR;
|
||||
bool sparse = config->preserve_sparse;
|
||||
bool preserve_executability = config->use_executability;
|
||||
|
||||
if (config && config->existing && !file_path_exists_secure(destination_path))
|
||||
if (config->existing && !file_path_exists_secure(destination_path))
|
||||
return FILE_SAVE_SKIPPED;
|
||||
if (config && config->ignore_existing && file_path_exists_secure(destination_path))
|
||||
if (config->ignore_existing && file_path_exists_secure(destination_path))
|
||||
return FILE_SAVE_SKIPPED;
|
||||
if (config && config->update &&
|
||||
file_destination_is_newer_secure(destination_path, file->metadata))
|
||||
if (config->update && file_destination_is_newer_secure(destination_path, file->metadata))
|
||||
return FILE_SAVE_SKIPPED;
|
||||
|
||||
FileMetadata adjusted_metadata;
|
||||
const FileMetadata* metadata = file->metadata;
|
||||
if (metadata && config && config->chmod_spec && *config->chmod_spec) {
|
||||
if (metadata && config->chmod_spec && *config->chmod_spec) {
|
||||
adjusted_metadata = *metadata;
|
||||
if (!chmod_apply(adjusted_metadata.mode, config->chmod_spec, &adjusted_metadata.mode))
|
||||
return FILE_SAVE_ERROR;
|
||||
@@ -75,9 +76,9 @@ static FileSaveResult file_stage_delayed_update(const char* root_directory,
|
||||
wiped by prepare), so the plain atomic temp+rename engine installs the
|
||||
complete file there. --temp-dir scratch is deliberately not layered on
|
||||
top of the delay-updates staging tree. */
|
||||
bool ok = file_to_disk_secure_with_fsync(staged_path, file->data->data, file->data->size, false,
|
||||
sparse, metadata, preserve_executability,
|
||||
config && config->use_fsync, NULL);
|
||||
bool ok =
|
||||
file_to_disk_secure_with_fsync(staged_path, file->data->data, file->data->size, false, sparse,
|
||||
metadata, preserve_executability, config->use_fsync, NULL);
|
||||
if (!ok) {
|
||||
free(staged_path);
|
||||
return FILE_SAVE_ERROR;
|
||||
@@ -796,8 +797,13 @@ int receive_manifest(int fd, const Config* config, int* next_status) {
|
||||
return *status_out == STATUS_FINISHED ? 0 : -1;
|
||||
}
|
||||
fprintf(stderr, "Deleting files not in manifest...\n");
|
||||
bool deletion_ok =
|
||||
delete_extras_limited(config->receive_root_directory, manifest, MAX_SERVER_DELETE_COUNT);
|
||||
/* With --delay-updates the staged (not yet published) files live directly
|
||||
under the receive root in the staging directory; the delete walker must
|
||||
not treat them as extras or it would remove every staged file before it
|
||||
can be published. */
|
||||
const char* skip_staging = config->delay_updates ? DELAY_UPDATES_STAGING_DIR : NULL;
|
||||
bool deletion_ok = delete_extras_limited(config->receive_root_directory, manifest,
|
||||
MAX_SERVER_DELETE_COUNT, skip_staging);
|
||||
array_list_delete(manifest);
|
||||
if (!deletion_ok)
|
||||
send_status(fd, STATUS_ERROR);
|
||||
|
||||
+15
-5
@@ -205,7 +205,8 @@ static bool is_dir_in_manifest(const char* rel_path, ArrayList* manifest) {
|
||||
}
|
||||
|
||||
static bool delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifest,
|
||||
size_t max_delete, size_t* deleted_count) {
|
||||
size_t max_delete, size_t* deleted_count,
|
||||
const char* skip_root_child) {
|
||||
int scanfd = dup(dirfd);
|
||||
if (scanfd < 0)
|
||||
return false;
|
||||
@@ -219,6 +220,13 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifes
|
||||
while ((entry = readdir(dir)) != NULL) {
|
||||
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
|
||||
continue;
|
||||
/* A --delay-updates run keeps its staging directory as a direct child of
|
||||
the receive root. Its contents are not manifest entries yet (they are
|
||||
published after deletion), so descending into it would delete every
|
||||
staged file as an "extra". Skip only the top-level staging name; nested
|
||||
directories with the same name are ordinary destination content. */
|
||||
if (rel_path[0] == '\0' && skip_root_child && strcmp(entry->d_name, skip_root_child) == 0)
|
||||
continue;
|
||||
char* child_rel = path_cat((char*)rel_path, entry->d_name);
|
||||
if (!child_rel) {
|
||||
operation_ok = false;
|
||||
@@ -240,7 +248,8 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifes
|
||||
int childfd = openat(dirfd, entry->d_name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
|
||||
bool child_removed = false;
|
||||
if (childfd >= 0) {
|
||||
child_removed = delete_extras_fd(childfd, child_rel, manifest, max_delete, deleted_count);
|
||||
child_removed = delete_extras_fd(childfd, child_rel, manifest, max_delete, deleted_count,
|
||||
skip_root_child);
|
||||
if (!child_removed)
|
||||
operation_ok = false;
|
||||
close(childfd);
|
||||
@@ -291,7 +300,8 @@ static bool delete_extras_fd(int dirfd, const char* rel_path, ArrayList* manifes
|
||||
return operation_ok;
|
||||
}
|
||||
|
||||
bool delete_extras_limited(const char* dest_root, ArrayList* manifest, size_t max_delete) {
|
||||
bool delete_extras_limited(const char* dest_root, ArrayList* manifest, size_t max_delete,
|
||||
const char* skip_root_child) {
|
||||
if (!manifest)
|
||||
return false;
|
||||
int rootfd;
|
||||
@@ -308,14 +318,14 @@ bool delete_extras_limited(const char* dest_root, ArrayList* manifest, size_t ma
|
||||
if (rootfd < 0)
|
||||
return false;
|
||||
size_t deleted_count = 0;
|
||||
bool ok = delete_extras_fd(rootfd, "", manifest, max_delete, &deleted_count);
|
||||
bool ok = delete_extras_fd(rootfd, "", manifest, max_delete, &deleted_count, skip_root_child);
|
||||
if (close(rootfd) != 0)
|
||||
ok = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool delete_extras(const char* dest_root, ArrayList* manifest) {
|
||||
return delete_extras_limited(dest_root, manifest, SIZE_MAX);
|
||||
return delete_extras_limited(dest_root, manifest, SIZE_MAX, NULL);
|
||||
}
|
||||
|
||||
bool has_path_traversal(const char* path) {
|
||||
|
||||
+6
-1
@@ -10,7 +10,12 @@ char* output_escape(const char* string, bool eight_bit_output);
|
||||
char* path_cat(const char* path1, const char* path2);
|
||||
bool glob_match(const char* pattern, const char* str);
|
||||
bool delete_extras(const char* dest_root, ArrayList* manifest);
|
||||
bool delete_extras_limited(const char* dest_root, ArrayList* manifest, size_t max_delete);
|
||||
/* Remove files/dirs under dest_root that are not listed in manifest. When
|
||||
skip_root_child is non-NULL, a direct child of dest_root with that exact
|
||||
name is left untouched (used to protect the --delay-updates staging
|
||||
directory, which holds files that are still to be published). */
|
||||
bool delete_extras_limited(const char* dest_root, ArrayList* manifest, size_t max_delete,
|
||||
const char* skip_root_child);
|
||||
bool utils_set_authorized_root(int fd, const char* canonical_path);
|
||||
/* The fd-only compatibility form is fail-closed for path-based operations;
|
||||
* callers should use utils_set_authorized_root with the canonical identity. */
|
||||
|
||||
@@ -12,7 +12,7 @@ from common import (
|
||||
PROJECT_ROOT, BUILD_DIR, TEST_DATA_DIR,
|
||||
run_client,
|
||||
generate_test_files, verify_transfer, clean_dir, make_result,
|
||||
get_dest_received_dir, CLIENT_CMD,
|
||||
get_dest_received_dir, CLIENT_CMD, ServerManager,
|
||||
)
|
||||
|
||||
SOURCE_DIR = os.path.join(TEST_DATA_DIR, "feature_source")
|
||||
@@ -1494,30 +1494,103 @@ class TestDelayUpdates:
|
||||
assert not os.path.isdir(os.path.join(dest, self.STAGING))
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_abort_publish_failure_installs_nothing(self, shared_server, mt):
|
||||
"""A deterministic publication failure must fail the transfer, leave no
|
||||
file in the final destination, and clean up the staging area. A plain
|
||||
file is planted where the final destination directory must be created,
|
||||
so the very first stage->publish rename fails (mkdir is impossible on
|
||||
top of a file even for root). Exercised in both single and -m modes so
|
||||
the multithreaded publish-once ordering is covered."""
|
||||
source = self._make_source("delay_abort_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "delay_abort_dst")
|
||||
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)."""
|
||||
source = os.path.join(TEST_DATA_DIR, "delay_delete_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "delay_delete_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
with open(os.path.join(source, "f.txt"), "wb") as fh:
|
||||
fh.write(b"AAAA")
|
||||
with open(os.path.join(source, "extra.txt"), "wb") as fh:
|
||||
fh.write(b"seed extra")
|
||||
with ServerManager() as server:
|
||||
server.start(extra_args=["--allow-delete"])
|
||||
result, _ = run_client(source, dest, port=server.port)
|
||||
assert result.returncode == 0, f"seed sync failed: {result.stderr[:200]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert _read_file(os.path.join(received, "extra.txt")) == b"seed extra"
|
||||
|
||||
# Second source state: f.txt changed, extra.txt removed from source.
|
||||
with open(os.path.join(source, "f.txt"), "wb") as fh:
|
||||
fh.write(b"BBBB")
|
||||
os.remove(os.path.join(source, "extra.txt"))
|
||||
|
||||
flags = ["--delete", "--delay-updates"] + (["-m"] if mt else [])
|
||||
result, _ = run_client(source, dest, flags=flags, port=server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"delete+delay-updates sync failed: {result.stderr[:200]}"
|
||||
assert _read_file(os.path.join(received, "f.txt")) == b"BBBB", \
|
||||
"changed file was not published after deletion"
|
||||
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))
|
||||
|
||||
def test_delay_updates_rejects_reserved_backup_dir(self):
|
||||
"""--backup-dir equal to the internal staging name must be rejected so
|
||||
an old backup can never be silently installed as the "new" file."""
|
||||
source = self._make_source("delay_reserved_bak_src")
|
||||
for variant, suffix in (("bare", ""), ("slash", "/")):
|
||||
dest = os.path.join(TEST_DATA_DIR, f"delay_reserved_bak_{variant}_dst")
|
||||
clean_dir(dest)
|
||||
flags = ["--delay-updates", "--backup", "--backup-dir",
|
||||
".fastsync-stage" + suffix]
|
||||
result, _ = run_client(source, dest, flags=flags, port=None)
|
||||
assert result.returncode != 0, \
|
||||
f"reserved --backup-dir '{suffix}' was accepted"
|
||||
assert not os.path.isdir(os.path.join(dest, self.STAGING)), \
|
||||
"staging directory created by a rejected run"
|
||||
|
||||
@pytest.mark.parametrize("remove_source_files", [False, True])
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_mid_publish_failure_keeps_published_no_rollback(self, shared_server, mt,
|
||||
remove_source_files):
|
||||
"""A stage->publish rename failing part way through publication must
|
||||
fail the whole transfer, keep the already-published top-level file (no
|
||||
rollback), leave the not-yet-published nested file absent, and clean up
|
||||
the staging area. A regular file is planted where the final "sub"
|
||||
directory must be created, so the nested rename fails (mkdir over a
|
||||
file is impossible even for root) while the top-level file, which is
|
||||
always staged first, publishes. With --remove-source-files the sender
|
||||
must keep every source because no success/outcome frame is ever sent."""
|
||||
source = os.path.join(TEST_DATA_DIR, "delay_mid_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "delay_mid_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
top_path = os.path.join(source, "top.txt")
|
||||
deep_path = os.path.join(source, "sub", "deep.txt")
|
||||
with open(top_path, "wb") as fh:
|
||||
fh.write(b"top payload\n")
|
||||
os.makedirs(os.path.dirname(deep_path))
|
||||
with open(deep_path, "wb") as fh:
|
||||
fh.write(b"deep payload\n")
|
||||
|
||||
received = get_dest_received_dir(dest, source)
|
||||
os.makedirs(os.path.dirname(received), exist_ok=True)
|
||||
with open(received, "wb") as fh:
|
||||
fh.write(b"blocks the destination directory")
|
||||
os.makedirs(received)
|
||||
with open(os.path.join(received, "sub"), "wb") as fh:
|
||||
fh.write(b"blocks the nested destination directory")
|
||||
|
||||
flags = ["--delay-updates"] + (["-m"] if mt else [])
|
||||
if remove_source_files:
|
||||
flags += ["--remove-source-files"]
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode != 0, "blocked destination publish did not fail"
|
||||
for root, _dirs, files in os.walk(source):
|
||||
for name in files:
|
||||
rel = os.path.relpath(os.path.join(root, name), source)
|
||||
assert not os.path.exists(os.path.join(received, rel)), \
|
||||
f"file appeared at final destination despite failed publish: {rel}"
|
||||
assert result.returncode != 0, "blocked nested publish did not fail"
|
||||
|
||||
# The top-level file was published before the nested rename failed and
|
||||
# is intentionally NOT rolled back.
|
||||
assert _read_file(os.path.join(received, "top.txt")) == b"top payload\n"
|
||||
# The nested file was never published.
|
||||
assert not os.path.lexists(os.path.join(received, "sub", "deep.txt")), \
|
||||
"nested file appeared despite a failed publish"
|
||||
assert not os.path.isdir(os.path.join(dest, self.STAGING)), \
|
||||
"staging leftovers after a failed publish"
|
||||
"staging leftovers after a failed mid-publish"
|
||||
# Sources survive: no success frame was sent, so a remove-source-files
|
||||
# sender must not delete anything.
|
||||
assert os.path.isfile(top_path)
|
||||
assert os.path.isfile(deep_path)
|
||||
|
||||
@pytest.mark.parametrize("mt", [False, True])
|
||||
def test_remove_source_files_keeps_receiver_skipped_source(self, shared_server, mt):
|
||||
|
||||
@@ -1287,6 +1287,27 @@ static void test_validate_config_delay_updates_rejects_inplace() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --backup-dir may not collide with the internal --delay-updates staging
|
||||
directory (with or without a trailing slash), or old backups would silently
|
||||
be installed as the "new" file. */
|
||||
static void test_validate_config_delay_updates_rejects_reserved_backup_dir() {
|
||||
static const char* const reserved[] = {".fastsync-stage", ".fastsync-stage/"};
|
||||
for (size_t i = 0; i < sizeof(reserved) / sizeof(reserved[0]); i++) {
|
||||
Config* cfg = valid_client_config();
|
||||
cfg->delay_updates = true;
|
||||
cfg->backup_dir = str_dup(reserved[i]);
|
||||
EXPECT_FALSE(validate_config(cfg));
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* A non-colliding backup dir is fine alongside --delay-updates. */
|
||||
Config* ok = valid_client_config();
|
||||
ok->delay_updates = true;
|
||||
ok->backup_dir = str_dup("backups");
|
||||
EXPECT_TRUE(validate_config(ok));
|
||||
config_delete(ok);
|
||||
}
|
||||
|
||||
void test_client_cli() {
|
||||
test_validate_config_required_paths();
|
||||
test_validate_config_incompatible_options();
|
||||
@@ -1368,4 +1389,5 @@ void test_client_cli() {
|
||||
test_parse_args_temp_dir();
|
||||
test_parse_args_delay_updates();
|
||||
test_validate_config_delay_updates_rejects_inplace();
|
||||
test_validate_config_delay_updates_rejects_reserved_backup_dir();
|
||||
}
|
||||
|
||||
@@ -408,6 +408,30 @@ static void test_config_temp_dir_roundtrip() {
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
static void test_config_delay_updates_reserved_backup_rejected() {
|
||||
if (is_running_under_valgrind())
|
||||
return;
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
c->delay_updates = true;
|
||||
c->backup_dir = str_dup(".fastsync-stage");
|
||||
/* The receiver-side wire validation must reject a --backup-dir that collides
|
||||
with the internal delay-updates staging directory. */
|
||||
EXPECT_FALSE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
|
||||
c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
c->delay_updates = true;
|
||||
c->backup_dir = str_dup("backups");
|
||||
EXPECT_TRUE(roundtrip_config_ok(c));
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
static void test_config_is_remote_dest() {
|
||||
/* Valid SSH-style destinations */
|
||||
EXPECT_TRUE(config_is_remote_dest("user@host:/path"));
|
||||
@@ -443,6 +467,7 @@ void test_config() {
|
||||
test_config_receive_truncated();
|
||||
test_config_string_null_vs_empty_roundtrip();
|
||||
test_config_temp_dir_roundtrip();
|
||||
test_config_delay_updates_reserved_backup_rejected();
|
||||
}
|
||||
test_config_is_remote_dest();
|
||||
}
|
||||
|
||||
@@ -274,7 +274,20 @@ out:
|
||||
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() {
|
||||
EXPECT_TRUE(delay_updates_staging_name_conflict(".fastsync-stage"));
|
||||
EXPECT_TRUE(delay_updates_staging_name_conflict(".fastsync-stage/"));
|
||||
EXPECT_TRUE(delay_updates_staging_name_conflict(".fastsync-stage///"));
|
||||
EXPECT_FALSE(delay_updates_staging_name_conflict(NULL));
|
||||
EXPECT_FALSE(delay_updates_staging_name_conflict(""));
|
||||
EXPECT_FALSE(delay_updates_staging_name_conflict("backups"));
|
||||
EXPECT_FALSE(delay_updates_staging_name_conflict(".fastsync-stage.bak"));
|
||||
}
|
||||
|
||||
void test_delay_updates() {
|
||||
test_delay_updates_reserved_name_helper();
|
||||
test_delay_updates_no_final_before_publish();
|
||||
test_delay_updates_publish_installs_files();
|
||||
test_delay_updates_cleanup_removes_staged();
|
||||
|
||||
Reference in New Issue
Block a user