feat: parity burn-down — inc-recursive, temp-dir, delete-before, fake-super, devices, daemon config #309

Merged
TapTap merged 15 commits from feat/parity-next into dev 2026-09-22 23:33:21 +02:00
12 changed files with 310 additions and 47 deletions
Showing only changes of commit 06e7aef5c9 - Show all commits
+34 -2
View File
@@ -53,7 +53,13 @@ bool receiver_send_final_success(int fd, const Config* config, const ReceiverOut
return send_status(fd, final_status); return send_status(fd, final_status);
size_t count = outcomes ? outcomes->count : 0; size_t count = outcomes ? outcomes->count : 0;
for (size_t i = 0; i < count; i++) { for (size_t i = 0; i < count; i++) {
Status per_file = outcomes->entries[i] == FILE_SAVE_WRITTEN ? STATUS_NEXT : STATUS_OK; Status per_file;
if (outcomes->entries[i] == FILE_SAVE_WRITTEN)
per_file = STATUS_NEXT;
else if (outcomes->entries[i] == FILE_SAVE_FAILED)
per_file = STATUS_ERROR;
else
per_file = STATUS_OK;
if (!send_status(fd, per_file)) if (!send_status(fd, per_file))
return false; return false;
} }
@@ -719,6 +725,11 @@ typedef struct {
ArrayList* would_delete; ArrayList* would_delete;
/* --info=del: actually-removed paths collected during the delete commit. */ /* --info=del: actually-removed paths collected during the delete commit. */
ArrayList* deleted_paths; ArrayList* deleted_paths;
/* Per-run count of entries that failed to materialize without aborting the
stream (currently ONLY a --devices mknod EPERM/EACCES). A nonzero count
makes the terminal frame carry a non-OK status so the client exits
non-zero, matching rsync's continue-and-exit-partial behavior. */
size_t failed_entries;
} ReceiverSaveContext; } ReceiverSaveContext;
static bool receiver_save_file(File* file, void* context_pointer) { static bool receiver_save_file(File* file, void* context_pointer) {
@@ -742,6 +753,11 @@ static bool receiver_save_file(File* file, void* context_pointer) {
count as matched data in the end-of-transfer report. */ count as matched data in the end-of-transfer report. */
if (result != FILE_SAVE_ERROR && file->matched_bytes > 0) if (result != FILE_SAVE_ERROR && file->matched_bytes > 0)
context->stats.matched_data += file->matched_bytes; context->stats.matched_data += file->matched_bytes;
/* --devices parity: a device node the receiver could not mknod (EPERM/EACCES)
is counted per-run but does not abort the transfer. The terminal frame
turns a nonzero count into a non-OK status so the client exits non-zero. */
if (result == FILE_SAVE_FAILED)
context->failed_entries++;
/* Protocol 2.28.0: receiver-observed literal bytes and the created-entry /* Protocol 2.28.0: receiver-observed literal bytes and the created-entry
breakdown (regular/dir/link/special) for the `--stats` report. */ breakdown (regular/dir/link/special) for the `--stats` report. */
if (result == FILE_SAVE_WRITTEN) if (result == FILE_SAVE_WRITTEN)
@@ -775,9 +791,25 @@ static void receiver_note_delete_limit(void* context_pointer) {
context->delete_limit_reached = true; context->delete_limit_reached = true;
} }
/* Terminal status for a run. A capped --delete limit wins (rsync exit 25);
otherwise any per-entry failure (for example an unprivileged --devices
mknod) makes the terminal frame non-OK so the client exits non-zero. rsync
reports 23 here; mapping the client's exact exit code to 23 is a separate,
pre-existing concern. A clean run keeps STATUS_OK. */
static Status receiver_final_status(bool delete_limit_reached, size_t failed_entries) {
if (delete_limit_reached)
return STATUS_DELETE_LIMIT;
return failed_entries > 0 ? STATUS_ERROR : STATUS_OK;
}
static bool receiver_send_success_frame(int fd, void* context_pointer) { static bool receiver_send_success_frame(int fd, void* context_pointer) {
ReceiverSaveContext* context = context_pointer; ReceiverSaveContext* context = context_pointer;
Status final_status = context->delete_limit_reached ? STATUS_DELETE_LIMIT : STATUS_OK; if (context->failed_entries > 0)
log_message(LOG_LEVEL_WARNING,
"%zu entr%s failed to materialize; continuing (partial transfer)",
context->failed_entries, context->failed_entries == 1 ? "y" : "ies");
Status final_status =
receiver_final_status(context->delete_limit_reached, context->failed_entries);
if (!receiver_send_stats_frame(fd, context->config, &context->stats, context->would_delete, if (!receiver_send_stats_frame(fd, context->config, &context->stats, context->would_delete,
context->deleted_paths)) context->deleted_paths))
return false; return false;
+8
View File
@@ -29,6 +29,7 @@ PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue*
context->deferred_manifest = NULL; context->deferred_manifest = NULL;
context->deferred_plans = NULL; context->deferred_plans = NULL;
context->delete_limit_reached = false; context->delete_limit_reached = false;
context->failed_entries = 0;
memset(&context->stats, 0, sizeof(context->stats)); memset(&context->stats, 0, sizeof(context->stats));
context->would_delete = NULL; context->would_delete = NULL;
context->deleted_paths = NULL; context->deleted_paths = NULL;
@@ -264,6 +265,13 @@ int write_thread(void* pipeline_context) {
receiver_stats_note_saved(&context->stats, file, created, created_dirs); receiver_stats_note_saved(&context->stats, file, created, created_dirs);
mtx_unlock(&context->mutex); mtx_unlock(&context->mutex);
} }
/* --devices parity: a device node that could not be mknod'ed is counted
per-run but does NOT abort the transfer. */
if (result == FILE_SAVE_FAILED) {
mtx_lock(&context->mutex);
context->failed_entries++;
mtx_unlock(&context->mutex);
}
if (result == FILE_SAVE_ERROR) { if (result == FILE_SAVE_ERROR) {
file_destroy(file); file_destroy(file);
pipeline_context_receiver_note_bytes_released(context, file_bytes); pipeline_context_receiver_note_bytes_released(context, file_bytes);
+5
View File
@@ -64,6 +64,11 @@ typedef struct PipelineContextReceiver {
/* --info=del actually-removed path list, collected by the deferred delete /* --info=del actually-removed path list, collected by the deferred delete
commit in server.c and reported in the STATUS_STATS frame. */ commit in server.c and reported in the STATUS_STATS frame. */
struct ArrayList* deleted_paths; struct ArrayList* deleted_paths;
/* Per-run count of entries that failed to materialize without aborting the
stream (currently ONLY a --devices mknod EPERM/EACCES). write_thread
increments it under `mutex`; server.c turns a nonzero count into a non-OK
terminal status so the client exits non-zero. */
size_t failed_entries;
} PipelineContextReceiver; } PipelineContextReceiver;
PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue* queue_receiver, PipelineContextReceiver* pipeline_context_receiver_create(Config* config, Queue* queue_receiver,
+7 -1
View File
@@ -1044,7 +1044,13 @@ static void server_run_mt_receiver(ServerSession* state) {
dir_metadata_list_apply(&context->dir_times, config->receive_root_directory, config); dir_metadata_list_apply(&context->dir_times, config->receive_root_directory, config);
} }
if (transfer_ok) { if (transfer_ok) {
Status final_status = context->delete_limit_reached ? STATUS_DELETE_LIMIT : STATUS_OK; if (context->failed_entries > 0)
log_message(LOG_LEVEL_WARNING,
"%zu entr%s failed to materialize; continuing (partial transfer)",
context->failed_entries, context->failed_entries == 1 ? "y" : "ies");
Status final_status = context->delete_limit_reached
? STATUS_DELETE_LIMIT
: (context->failed_entries > 0 ? STATUS_ERROR : STATUS_OK);
/* Emit the optional wire-stats record first (protocol 2.25.0), then the /* Emit the optional wire-stats record first (protocol 2.25.0), then the
success/outcome frame, exactly like the single-threaded receiver. */ success/outcome frame, exactly like the single-threaded receiver. */
if (!receiver_send_stats_frame(state->fd, config, &context->stats, context->would_delete, if (!receiver_send_stats_frame(state->fd, config, &context->stats, context->would_delete,
+27 -21
View File
@@ -1182,7 +1182,8 @@ int file_open_temp_dir(const char* dir_path) {
* destination file) and best-effort: a per-attribute or privilege failure is * destination file) and best-effort: a per-attribute or privilege failure is
* logged and skipped, never fatal. */ * logged and skipped, never fatal. */
static void restore_extra_fd(int fd, const FileMetadata* metadata, const FileXattrList* xattrs, static void restore_extra_fd(int fd, const FileMetadata* metadata, const FileXattrList* xattrs,
bool fake_super, FileAttrPolicy policy) { bool fake_super, FileAttrPolicy policy, uint32_t fake_super_rdev_major,
uint32_t fake_super_rdev_minor) {
xattr_apply_fd(fd, xattrs); xattr_apply_fd(fd, xattrs);
if (fake_super && metadata) { if (fake_super && metadata) {
/* Record the ownership that WOULD have been applied: when an explicit /* Record the ownership that WOULD have been applied: when an explicit
@@ -1197,7 +1198,8 @@ static void restore_extra_fd(int fd, const FileMetadata* metadata, const FileXat
uint32_t store_gid; uint32_t store_gid;
identity_resolve_storage_ids((int32_t)metadata->uid, (int32_t)metadata->gid, &store_uid, identity_resolve_storage_ids((int32_t)metadata->uid, (int32_t)metadata->gid, &store_uid,
&store_gid); &store_gid);
fake_super_store_fd(fd, store_uid, store_gid, (uint32_t)metadata->mode, 0, 0); fake_super_store_fd(fd, store_uid, store_gid, (uint32_t)metadata->mode, fake_super_rdev_major,
fake_super_rdev_minor);
fake_super_restore_fd(fd, policy); fake_super_restore_fd(fd, policy);
} }
} }
@@ -1207,7 +1209,8 @@ file_to_disk_secure_impl(const char* path, const void* data, unsigned long long
bool inplace, bool sparse, bool preallocate, const FileMetadata* metadata, bool inplace, bool sparse, bool preallocate, const FileMetadata* metadata,
FileAttrPolicy policy, bool update, bool no_replace, bool use_fsync, FileAttrPolicy policy, bool update, bool no_replace, bool use_fsync,
const char* temp_dir, const FileXattrList* xattrs, bool fake_super, const char* temp_dir, const FileXattrList* xattrs, bool fake_super,
bool keep_partial, unsigned* dirs_created, const char* count_floor) { bool keep_partial, unsigned* dirs_created, const char* count_floor,
uint32_t fake_super_rdev_major, uint32_t fake_super_rdev_minor) {
char* leaf = NULL; char* leaf = NULL;
int dirfd = file_open_secure_parent_counted(path, &leaf, true, dirs_created, count_floor); int dirfd = file_open_secure_parent_counted(path, &leaf, true, dirs_created, count_floor);
if (dirfd < 0) if (dirfd < 0)
@@ -1314,7 +1317,8 @@ file_to_disk_secure_impl(const char* path, const void* data, unsigned long long
} }
} }
if (ok) if (ok)
restore_extra_fd(fd, metadata, xattrs, fake_super, policy); restore_extra_fd(fd, metadata, xattrs, fake_super, policy, fake_super_rdev_major,
fake_super_rdev_minor);
if (ok && use_fsync) if (ok && use_fsync)
ok = fsync(fd) == 0; ok = fsync(fd) == 0;
} }
@@ -1432,7 +1436,8 @@ file_to_disk_secure_impl(const char* path, const void* data, unsigned long long
} }
} }
if (ok) if (ok)
restore_extra_fd(fd, metadata, xattrs, fake_super, policy); restore_extra_fd(fd, metadata, xattrs, fake_super, policy, fake_super_rdev_major,
fake_super_rdev_minor);
if (ok && use_fsync) if (ok && use_fsync)
ok = fsync(fd) == 0; ok = fsync(fd) == 0;
} }
@@ -1500,7 +1505,8 @@ file_to_disk_secure_impl(const char* path, const void* data, unsigned long long
"non-atomic copy into the destination directory"); "non-atomic copy into the destination directory");
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata, return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
policy, update, no_replace, use_fsync, NULL, xattrs, fake_super, policy, update, no_replace, use_fsync, NULL, xattrs, fake_super,
keep_partial, dirs_created, count_floor); keep_partial, dirs_created, count_floor, fake_super_rdev_major,
fake_super_rdev_minor);
} }
return ok; return ok;
} }
@@ -1510,7 +1516,7 @@ bool file_to_disk_secure(const char* path, const void* data, unsigned long long
FileAttrPolicy policy, const char* temp_dir) { FileAttrPolicy policy, const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata, return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
policy, false, false, false, temp_dir, NULL, false, false, NULL, policy, false, false, false, temp_dir, NULL, false, false, NULL,
NULL); NULL, 0, 0);
} }
bool file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size, bool file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size,
@@ -1519,7 +1525,7 @@ bool file_to_disk_secure_update(const char* path, const void* data, unsigned lon
const char* temp_dir) { const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata, return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
policy, true, false, false, temp_dir, NULL, false, false, NULL, policy, true, false, false, temp_dir, NULL, false, false, NULL,
NULL); NULL, 0, 0);
} }
bool file_to_disk_secure_with_fsync(const char* path, const void* data, bool file_to_disk_secure_with_fsync(const char* path, const void* data,
@@ -1528,7 +1534,7 @@ bool file_to_disk_secure_with_fsync(const char* path, const void* data,
FileAttrPolicy policy, bool use_fsync, const char* temp_dir) { FileAttrPolicy policy, bool use_fsync, const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata, return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
policy, false, false, use_fsync, temp_dir, NULL, false, false, policy, false, false, use_fsync, temp_dir, NULL, false, false,
NULL, NULL); NULL, NULL, 0, 0);
} }
bool file_to_disk_secure_no_replace(const char* path, const void* data, bool file_to_disk_secure_no_replace(const char* path, const void* data,
@@ -1537,7 +1543,7 @@ bool file_to_disk_secure_no_replace(const char* path, const void* data,
const char* temp_dir) { const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, false, sparse, preallocate, metadata, return file_to_disk_secure_impl(path, data, data_size, false, sparse, preallocate, metadata,
policy, false, true, false, temp_dir, NULL, false, false, NULL, policy, false, true, false, temp_dir, NULL, false, false, NULL,
NULL); NULL, 0, 0);
} }
/* Receiver write-path variant that also applies the per-file xattrs (-X/-A) /* Receiver write-path variant that also applies the per-file xattrs (-X/-A)
@@ -1552,19 +1558,19 @@ bool file_to_disk_secure_attrs(const char* path, const void* data, unsigned long
bool fake_super, bool keep_partial, const char* temp_dir) { bool fake_super, bool keep_partial, const char* temp_dir) {
return file_to_disk_secure_attrs_counted(path, data, data_size, inplace, sparse, preallocate, return file_to_disk_secure_attrs_counted(path, data, data_size, inplace, sparse, preallocate,
metadata, policy, update, no_replace, use_fsync, xattrs, metadata, policy, update, no_replace, use_fsync, xattrs,
fake_super, keep_partial, temp_dir, NULL, NULL); fake_super, keep_partial, temp_dir, NULL, NULL, 0, 0);
} }
bool file_to_disk_secure_attrs_counted(const char* path, const void* data, bool file_to_disk_secure_attrs_counted(
unsigned long long data_size, bool inplace, bool sparse, const char* path, const void* data, unsigned long long data_size, bool inplace, bool sparse,
bool preallocate, const FileMetadata* metadata, bool preallocate, const FileMetadata* metadata, FileAttrPolicy policy, bool update,
FileAttrPolicy policy, bool update, bool no_replace, bool no_replace, bool use_fsync, const FileXattrList* xattrs, bool fake_super,
bool use_fsync, const FileXattrList* xattrs, bool fake_super, bool keep_partial, const char* temp_dir, unsigned* dirs_created, const char* count_floor,
bool keep_partial, const char* temp_dir, uint32_t fake_super_rdev_major, uint32_t fake_super_rdev_minor) {
unsigned* dirs_created, const char* count_floor) {
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata, return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
policy, update, no_replace, use_fsync, temp_dir, xattrs, policy, update, no_replace, use_fsync, temp_dir, xattrs,
fake_super, keep_partial, dirs_created, count_floor); fake_super, keep_partial, dirs_created, count_floor,
fake_super_rdev_major, fake_super_rdev_minor);
} }
/* Atomic --link-dest install. The destination is replaced (via a temporary /* Atomic --link-dest install. The destination is replaced (via a temporary
@@ -1694,7 +1700,7 @@ static bool file_copy_basis_stream_impl(const char* path, const char* basis_path
wrote = false; wrote = false;
} }
if (wrote) if (wrote)
restore_extra_fd(fd, metadata, xattrs, fake_super, policy); restore_extra_fd(fd, metadata, xattrs, fake_super, policy, 0, 0);
if (wrote && use_fsync) if (wrote && use_fsync)
wrote = fsync(fd) == 0; wrote = fsync(fd) == 0;
if (close(fd) != 0) if (close(fd) != 0)
@@ -1843,7 +1849,7 @@ static bool file_to_disk_secure_link_impl(const char* path, const char* basis_pa
return true; return true;
return file_to_disk_secure_attrs_counted( return file_to_disk_secure_attrs_counted(
path, data, data_size, false, false, preallocate, metadata, policy, false, false, use_fsync, path, data, data_size, false, false, preallocate, metadata, policy, false, false, use_fsync,
xattrs, fake_super, false, temp_dir, dirs_created, count_floor); xattrs, fake_super, false, temp_dir, dirs_created, count_floor, 0, 0);
} }
if (scratch_dirfd >= 0) if (scratch_dirfd >= 0)
+6 -7
View File
@@ -182,13 +182,12 @@ bool file_copy_basis_stream_attrs(const char* path, const char* basis_path,
* confined secure walk had to create that lie strictly below `count_floor` (a * confined secure walk had to create that lie strictly below `count_floor` (a
* receive-root-relative prefix, or NULL for all). Used to reproduce rsync's * receive-root-relative prefix, or NULL for all). Used to reproduce rsync's
* `Number of created files` directory count on a fresh destination. */ * `Number of created files` directory count on a fresh destination. */
bool file_to_disk_secure_attrs_counted(const char* path, const void* data, bool file_to_disk_secure_attrs_counted(
unsigned long long data_size, bool inplace, bool sparse, const char* path, const void* data, unsigned long long data_size, bool inplace, bool sparse,
bool preallocate, const FileMetadata* metadata, bool preallocate, const FileMetadata* metadata, FileAttrPolicy policy, bool update,
FileAttrPolicy policy, bool update, bool no_replace, bool no_replace, bool use_fsync, const FileXattrList* xattrs, bool fake_super,
bool use_fsync, const FileXattrList* xattrs, bool fake_super, bool keep_partial, const char* temp_dir, unsigned* dirs_created, const char* count_floor,
bool keep_partial, const char* temp_dir, uint32_t fake_super_rdev_major, uint32_t fake_super_rdev_minor);
unsigned* dirs_created, const char* count_floor);
bool file_to_disk_secure_link_attrs_counted(const char* path, const char* basis_path, bool file_to_disk_secure_link_attrs_counted(const char* path, const char* basis_path,
const void* data, unsigned long long data_size, const void* data, unsigned long long data_size,
bool preallocate, const FileMetadata* metadata, bool preallocate, const FileMetadata* metadata,
+21 -12
View File
@@ -401,12 +401,13 @@ bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode) {
* *
* Privilege gating: making a real device node requires CAP_MKNOD (root); making * Privilege gating: making a real device node requires CAP_MKNOD (root); making
* a FIFO works unprivileged (mkfifo). A device node whose mknodat() fails with * a FIFO works unprivileged (mkfifo). A device node whose mknodat() fails with
* EPERM/EACCES is a genuine transfer error (rsync parity: rsync reports the * EPERM/EACCES is a PER-ENTRY failure (rsync parity: rsync reports the mknod
* mknod failure and the run exits partial, code 23). Only the unprivileged * failure, still transfers the rest, and exits partial, code 23), reported as
* FIFO/socket (--specials) path keeps the best-effort skip, because those are * FILE_SAVE_FAILED so the receiver counts it and continues. Only the
* normally creatable without privilege and a failure there is environmental. * unprivileged FIFO/socket (--specials) path keeps the best-effort skip,
* CI runs non-root, so device creation is expected to fail there; only a FIFO * because those are normally creatable without privilege and a failure there is
* is honestly assertable unprivileged. * environmental. CI runs non-root, so device creation is expected to fail
* there; only a FIFO is honestly assertable unprivileged.
* *
* Confinement: the parent directory is opened fd-relative below the receive * Confinement: the parent directory is opened fd-relative below the receive
* root (file_open_secure_parent: O_NOFOLLOW, no "..", root-checked) and the * root (file_open_secure_parent: O_NOFOLLOW, no "..", root-checked) and the
@@ -548,10 +549,10 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
const char* shown_path = escaped_path ? escaped_path : "<allocation failed>"; const char* shown_path = escaped_path ? escaped_path : "<allocation failed>";
if (is_char || is_blk) { if (is_char || is_blk) {
/* rsync parity: a device node that cannot be created (no CAP_MKNOD, or /* rsync parity: a device node that cannot be created (no CAP_MKNOD, or
* super-user activities not permitted) is a genuine transfer error. * super-user activities not permitted) is a per-entry failure. rsync
* rsync reports `mknod ".../node" failed: ...` and the run exits * logs `mknod ".../node" failed: ...`, still transfers the remaining
* partial (23); FastSync surfaces it through the outcome aggregation * files, and exits partial (23); FastSync logs it, counts it, and
* instead of silently skipping the entry. FIFO/socket creation * continues rather than aborting the stream. FIFO/socket creation
* (--specials) keeps the best-effort skip path below. */ * (--specials) keeps the best-effort skip path below. */
log_message(LOG_LEVEL_ERROR, log_message(LOG_LEVEL_ERROR,
"cannot create %s %s: %s\n" "cannot create %s %s: %s\n"
@@ -561,7 +562,7 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
close(parent_fd); close(parent_fd);
free(leaf); free(leaf);
free(destination); free(destination);
return FILE_SAVE_ERROR; return FILE_SAVE_FAILED;
} }
/* Missing CAP_MKNOD / parent write permission for a FIFO/socket: the /* Missing CAP_MKNOD / parent write permission for a FIFO/socket: the
environment cannot create the node, so skip instead of failing the environment cannot create the node, so skip instead of failing the
@@ -936,6 +937,14 @@ static bool file_save_try_special_dispatch(const FileSavePlan* plan, bool* creat
/* Device/special node (--devices/--specials): recreate the node instead of /* Device/special node (--devices/--specials): recreate the node instead of
writing content (privilege-gated, confined, rdev-validated). */ writing content (privilege-gated, confined, rdev-validated). */
if (file->is_special) { if (file->is_special) {
/* Under --fake-super rsync never mknod()s a device: it writes a regular
empty file and records the real rdev in user.rsync.%stat. Fall through to
the ordinary writer so the device round-trips (its S_IFMT mode bits and
rdev are parked in the record). Without --fake-super the node is
recreated (or, when privilege is refused, handled per-entry). */
mode_t special_mode = file->metadata ? file->metadata->mode : 0;
if (config && config->fake_super && (S_ISCHR(special_mode) || S_ISBLK(special_mode)))
return false;
*out = file_save_special_to_disk(plan->root_directory, file, config, created); *out = file_save_special_to_disk(plan->root_directory, file, config, created);
return true; return true;
} }
@@ -1110,7 +1119,7 @@ static bool file_save_install_data(FileSavePlan* plan, const FileMetadata* metad
config && config->preallocate, metadata, plan->policy, config && config->update, config && config->preallocate, metadata, plan->policy, config && config->update,
config && config->ignore_existing, config && config->use_fsync, file->xattrs, config && config->ignore_existing, config && config->use_fsync, file->xattrs,
config ? config->fake_super : false, config ? config->partial : false, plan->confined_temp, config ? config->fake_super : false, config ? config->partial : false, plan->confined_temp,
created_dirs, count_floor); created_dirs, count_floor, (uint32_t)file->rdev_major, (uint32_t)file->rdev_minor);
} }
free(count_floor); free(count_floor);
return ok; return ok;
+10 -2
View File
@@ -12,8 +12,16 @@
/* Outcome of a single file_save_to_disk operation. The receiver needs to /* Outcome of a single file_save_to_disk operation. The receiver needs to
distinguish "written" from "skipped" so --remove-source-files can be told distinguish "written" from "skipped" so --remove-source-files can be told
which sources were actually stored. */ which sources were actually stored. FILE_SAVE_FAILED is a per-entry failure
typedef enum { FILE_SAVE_ERROR = 0, FILE_SAVE_WRITTEN = 1, FILE_SAVE_SKIPPED = 2 } FileSaveResult; (for example a device node that mknodat() refused with EPERM/EACCES): it is
logged and counted by the receiver but does NOT abort the transfer, matching
rsync's continue-and-exit-partial behavior. */
typedef enum {
FILE_SAVE_ERROR = 0,
FILE_SAVE_WRITTEN = 1,
FILE_SAVE_SKIPPED = 2,
FILE_SAVE_FAILED = 3
} FileSaveResult;
bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode); bool file_special_rdev_valid(int32_t major, int32_t minor, mode_t mode);
+52 -1
View File
@@ -7,6 +7,7 @@
#include "utils.h" #include "utils.h"
#include "file_types.h" #include "file_types.h"
#include <errno.h> #include <errno.h>
#include <limits.h>
#include <stdint.h> #include <stdint.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -386,6 +387,56 @@ void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, uint
} }
} }
/* Parse rsync's `user.rsync.%stat` grammar strictly:
* "<octal st_mode> <rdev_major>,<rdev_minor> <uid>:<gid>"
* Every field is parsed with strtoul() so an out-of-range value is a clean
* rejection rather than the undefined behavior sscanf("%u") exhibited, each
* field is range-checked against the same bounds the wire validator uses, and
* the whole record must be consumed (only trailing whitespace is tolerated) so
* trailing garbage is refused. Returns false on any malformed input. */
static bool fake_super_parse_stat(const char* record, unsigned* mode_out, unsigned* rdev_major_out,
unsigned* rdev_minor_out, unsigned* uid_out, unsigned* gid_out) {
if (!record)
return false;
char* end = NULL;
const char* p = record;
errno = 0;
unsigned long mode = strtoul(p, &end, 8);
if (errno != 0 || end == p || mode > (unsigned long)UINT_MAX || *end != ' ')
return false;
p = end + 1;
errno = 0;
unsigned long rdev_major = strtoul(p, &end, 10);
if (errno != 0 || end == p || rdev_major > 0xffffUL || *end != ',')
return false;
p = end + 1;
errno = 0;
unsigned long rdev_minor = strtoul(p, &end, 10);
if (errno != 0 || end == p || rdev_minor > 0x00ffffffUL || *end != ' ')
return false;
p = end + 1;
errno = 0;
unsigned long uid = strtoul(p, &end, 10);
if (errno != 0 || end == p || uid > (unsigned long)UINT_MAX || *end != ':')
return false;
p = end + 1;
errno = 0;
unsigned long gid = strtoul(p, &end, 10);
if (errno != 0 || end == p || gid > (unsigned long)UINT_MAX)
return false;
p = end;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r')
p++;
if (*p != '\0')
return false;
*mode_out = (unsigned)mode;
*rdev_major_out = (unsigned)rdev_major;
*rdev_minor_out = (unsigned)rdev_minor;
*uid_out = (unsigned)uid;
*gid_out = (unsigned)gid;
return true;
}
/* --fake-super replay: read the freshly-stored record and re-apply its /* --fake-super replay: read the freshly-stored record and re-apply its
* permission bits fd-relative. The recorded uid/gid are retained for a later * permission bits fd-relative. The recorded uid/gid are retained for a later
* privileged restore but are NEVER chowned here: --fake-super only RECORDS * privileged restore but are NEVER chowned here: --fake-super only RECORDS
@@ -403,7 +454,7 @@ bool fake_super_restore_fd(int fd, FileAttrPolicy policy) {
return false; /* absent or filesystem without xattrs: silent no-op */ return false; /* absent or filesystem without xattrs: silent no-op */
record[len] = '\0'; record[len] = '\0';
unsigned ul_mode, rdev_major, rdev_minor, ul_uid, ul_gid; unsigned ul_mode, rdev_major, rdev_minor, ul_uid, ul_gid;
if (sscanf(record, "%o %u,%u %u:%u", &ul_mode, &rdev_major, &rdev_minor, &ul_uid, &ul_gid) != 5) if (!fake_super_parse_stat(record, &ul_mode, &rdev_major, &rdev_minor, &ul_uid, &ul_gid))
return false; /* malformed record: skip, never fatal */ return false; /* malformed record: skip, never fatal */
/* --fake-super NEVER performs a real chown: that would defeat the whole point /* --fake-super NEVER performs a real chown: that would defeat the whole point
@@ -64,6 +64,13 @@ def test_read_batch_absolute_temp_dir_inside_root_accepted(tmp_path):
clean_dir(dest) clean_dir(dest)
scratch = os.path.join(dest, "scratch") scratch = os.path.join(dest, "scratch")
os.makedirs(scratch) os.makedirs(scratch)
# Stamp the scratch dir with an old mtime so the test can prove the receiver
# really created (and then removed) its temp file there: the directory mtime
# changes when an entry is created/removed, so a silently-ignored --temp-dir
# would leave the stamp untouched. An empty scratch dir alone does not
# distinguish "used and cleaned up" from "never used".
stale_mtime = 946684800 # 2000-01-01
os.utime(scratch, (stale_mtime, stale_mtime))
batch = _make_batch(str(tmp_path), source) batch = _make_batch(str(tmp_path), source)
result = _run(["--read-batch", batch, dest, "--temp-dir", scratch]) result = _run(["--read-batch", batch, dest, "--temp-dir", scratch])
@@ -73,6 +80,9 @@ def test_read_batch_absolute_temp_dir_inside_root_accepted(tmp_path):
for rel, data in FILES.items(): for rel, data in FILES.items():
assert _read(os.path.join(received, rel)) == data, f"content mismatch for {rel}" assert _read(os.path.join(received, rel)) == data, f"content mismatch for {rel}"
assert os.listdir(scratch) == [], "scratch dir was not left clean" assert os.listdir(scratch) == [], "scratch dir was not left clean"
assert os.stat(scratch).st_mtime != stale_mtime, (
"--temp-dir scratch dir was never written to (temp file not created there)"
)
@pytest.mark.ci @pytest.mark.ci
@@ -98,6 +108,11 @@ def test_tcp_absolute_temp_dir_inside_root_accepted(shared_server):
clean_dir(dest) clean_dir(dest)
scratch = os.path.join(dest, "scratch") scratch = os.path.join(dest, "scratch")
os.makedirs(scratch) os.makedirs(scratch)
# See test_read_batch_absolute_temp_dir_inside_root_accepted: the stale
# mtime makes actual scratch usage observable (the temp file creation and
# removal bump the directory mtime).
stale_mtime = 946684800 # 2000-01-01
os.utime(scratch, (stale_mtime, stale_mtime))
result, _ = run_client(source, dest, flags=["--temp-dir", scratch], port=shared_server.port) result, _ = run_client(source, dest, flags=["--temp-dir", scratch], port=shared_server.port)
assert result.returncode == 0, (result.stdout, result.stderr)[:300] assert result.returncode == 0, (result.stdout, result.stderr)[:300]
@@ -105,6 +120,9 @@ def test_tcp_absolute_temp_dir_inside_root_accepted(shared_server):
for rel, data in FILES.items(): for rel, data in FILES.items():
assert _read(os.path.join(received, rel)) == data, f"content mismatch for {rel}" assert _read(os.path.join(received, rel)) == data, f"content mismatch for {rel}"
assert os.listdir(scratch) == [], "scratch dir was not left clean" assert os.listdir(scratch) == [], "scratch dir was not left clean"
assert os.stat(scratch).st_mtime != stale_mtime, (
"--temp-dir scratch dir was never written to (temp file not created there)"
)
def test_tcp_absolute_temp_dir_outside_root_rejected(shared_server): def test_tcp_absolute_temp_dir_outside_root_rejected(shared_server):
+104 -1
View File
@@ -9,7 +9,9 @@
#include "charset.h" #include "charset.h"
#include "utils.h" #include "utils.h"
#include "protocol.h" #include "protocol.h"
#include "xattr.h"
#include "test_utils.h" #include "test_utils.h"
#include <errno.h>
#include <fcntl.h> #include <fcntl.h>
#include <limits.h> #include <limits.h>
#include <stdlib.h> #include <stdlib.h>
@@ -17,6 +19,7 @@
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/sysmacros.h> #include <sys/sysmacros.h>
#include <sys/wait.h> #include <sys/wait.h>
#include <sys/xattr.h>
#include <time.h> #include <time.h>
#include <unistd.h> #include <unistd.h>
@@ -348,7 +351,8 @@ static void test_file_save_to_disk_temp_dir_confined() {
mkdir(root, 0755); mkdir(root, 0755);
mkdir("test_temp_confine_tmp/scratch", 0755); mkdir("test_temp_confine_tmp/scratch", 0755);
mkdir("test_temp_confine_tmp/abs_scratch", 0755); mkdir("test_temp_confine_tmp/abs_scratch", 0755);
EXPECT_NOT_NULL(realpath("test_temp_confine_tmp/abs_scratch", inside_abs)); if (!realpath("test_temp_confine_tmp/abs_scratch", inside_abs))
EXPECT_FAIL("realpath(abs_scratch) failed; inside_abs would be uninitialized");
mkdir(outside, 0755); mkdir(outside, 0755);
File* f = file_create("file.txt"); File* f = file_create("file.txt");
@@ -1327,6 +1331,103 @@ static void test_special_socket_recreated() {
rmdir(root); rmdir(root);
} }
/* --fake-super device round-trip (rsync parity): a char/block device must be
* materialized as a REGULAR empty file whose user.rsync.%stat records the real
* rdev -- never as an mknod'ed node -- even on a privileged receiver. This is
* the non-privileged unit counterpart to the setpriv integration test (which
* the PR gate excludes). */
static void test_fake_super_device_writes_regular_file_with_rdev() {
const char* root = "test_fake_super_dev_tmp";
const char* node = "test_fake_super_dev_tmp/cdev";
unlink(node);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->fake_super = true;
cfg->preserve_devices = true;
cfg->preserve_perms = true;
cfg->use_metadata = true;
cfg->use_xattrs = true;
FileMetadata meta;
memset(&meta, 0, sizeof(meta));
meta.mode = S_IFCHR | 0644;
File* f = file_create("cdev");
EXPECT_NOT_NULL(f);
f->is_special = true;
f->rdev_major = 1;
f->rdev_minor = 3;
f->metadata = &meta;
EXPECT_EQ_INT(file_save_to_disk_full(root, f, cfg), FILE_SAVE_WRITTEN);
struct stat st;
EXPECT_EQ_INT(lstat(node, &st), 0);
EXPECT_TRUE(S_ISREG(st.st_mode)); /* never a real device node */
EXPECT_EQ_INT((int)st.st_size, 0);
char value[128] = {0};
ssize_t got = getxattr(node, FAKESUPER_XATTR, value, sizeof(value) - 1);
EXPECT_TRUE(got > 0);
EXPECT_EQ_STR(value, "20644 1,3 0:0"); /* the REAL rdev, not 0,0 */
f->metadata = NULL;
file_destroy(f);
config_delete(cfg);
unlink(node);
rmdir(root);
}
/* A char/block device that mknodat() refuses (EPERM/EACCES on an unprivileged
* receiver) must be a PER-ENTRY failure -- FILE_SAVE_FAILED, which the receiver
* counts and continues past -- never the fatal FILE_SAVE_ERROR that aborts the
* stream. The unit suite normally runs as root, so drop the effective uid to
* make the kernel refusal deterministic. */
static void test_device_mknod_failure_is_per_entry() {
const char* root = "test_device_eperm_tmp";
const char* node = "test_device_eperm_tmp/cdev";
unlink(node);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0777), 0);
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->preserve_devices = true;
cfg->use_metadata = true;
FileMetadata meta;
memset(&meta, 0, sizeof(meta));
meta.mode = S_IFCHR | 0644;
File* f = file_create("cdev");
EXPECT_NOT_NULL(f);
f->is_special = true;
f->rdev_major = 1;
f->rdev_minor = 3;
f->metadata = &meta;
uid_t saved = geteuid();
bool dropped = false;
if (saved == 0 && seteuid(65534) == 0)
dropped = true;
FileSaveResult result = file_save_to_disk_full(root, f, cfg);
if (dropped)
EXPECT_EQ_INT(seteuid(saved), 0);
EXPECT_EQ_INT(result, FILE_SAVE_FAILED);
/* Nothing was created: no device node and no regular-file fallback. */
struct stat st;
EXPECT_EQ_INT(lstat(node, &st), -1);
f->metadata = NULL;
file_destroy(f);
config_delete(cfg);
rmdir(root);
}
static void test_inplace_overwrite_truncates_shorter_payload() { static void test_inplace_overwrite_truncates_shorter_payload() {
const char* root = "test_inplace_trunc_tmp"; const char* root = "test_inplace_trunc_tmp";
const char* path = "test_inplace_trunc_tmp/big.txt"; const char* path = "test_inplace_trunc_tmp/big.txt";
@@ -2408,6 +2509,8 @@ void test_file() {
test_new_file_mode_honors_source_and_umask(); test_new_file_mode_honors_source_and_umask();
test_special_fifo_mode_honors_source_and_umask(); test_special_fifo_mode_honors_source_and_umask();
test_special_socket_recreated(); test_special_socket_recreated();
test_fake_super_device_writes_regular_file_with_rdev();
test_device_mknod_failure_is_per_entry();
test_inplace_overwrite_truncates_shorter_payload(); test_inplace_overwrite_truncates_shorter_payload();
test_inplace_refuses_fifo_destination(); test_inplace_refuses_fifo_destination();
test_inplace_refuses_device_destination(); test_inplace_refuses_device_destination();
+18
View File
@@ -450,6 +450,24 @@ static void test_fake_super_rsync_format() {
EXPECT_EQ_INT((int)st.st_uid, (int)before.st_uid); EXPECT_EQ_INT((int)st.st_uid, (int)before.st_uid);
EXPECT_EQ_INT((int)st.st_gid, (int)before.st_gid); EXPECT_EQ_INT((int)st.st_gid, (int)before.st_gid);
/* Hardened parser: an out-of-range field (previously UB via sscanf("%u")),
a missing field, or trailing garbage is rejected cleanly instead of being
silently accepted. */
const char* malformed[] = {
"20644 65536,3 111:222", /* major > 0xffff */
"20644 1,16777216 111:222", /* minor > 0xffffff */
"20644 1,3 111:222 trailing", /* trailing garbage */
"20644 1,3 111", /* missing gid */
"20644 1,3 4294967296:222", /* uid > UINT_MAX */
"20644 1,3 111:4294967296", /* gid > UINT_MAX */
"99999999999999999999 1,3 0:0", /* mode overflow */
"", /* empty record */
};
for (size_t i = 0; i < sizeof(malformed) / sizeof(malformed[0]); i++) {
EXPECT_EQ_INT((int)fsetxattr(fd, FAKESUPER_XATTR, malformed[i], strlen(malformed[i]), 0), 0);
EXPECT_FALSE(fake_super_restore_fd(fd, policy));
}
close(fd); close(fd);
unlink(path); unlink(path);
} }