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,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. */
|
||||
|
||||
Reference in New Issue
Block a user