feat: per-attribute preservation flags -p/-t/-o/-g with --no-* negations (protocol 2.22.0)

Split FastSync's single use_metadata bundle into four independent rsync-parity attributes: preserve_perms, preserve_times, preserve_owner, preserve_group. use_metadata is now a derived transport bit (config_derived_use_metadata).

CLI: real -p/--perms, -t/--times, -o/--owner, -g/--group plus --no-perms/--no-times/--no-owner/--no-group (short and long) and --no-preserve; -a is now rsync -rlptgoD; --preserve = -pt; -A implies -p; -X does not; --chmod implies -p; --usermap/--groupmap/--chown imply owner/group per side; --incremental/--delta still auto-preserve unless negated.

Receiver: per-attribute FileAttrPolicy gating for files, dirs (modes applied at end of transfer), symlinks and specials; rsync -E read-bit rule; new files get source_mode & ~umask sanitized (no group/other write); per-side identity resolution; deferred directory metadata; batch dir-metadata replay; daemon modules without 'client owner = yes' no longer refuse plain -a but force super off (no ownership) with a warning.

Wire: PROTOCOL_VERSION 2.21.0 -> 2.22.0 (four appended config bools, golden 653 / 95530566005420798). FileMetadata/chunk/batch framing unchanged. Docs/CHANGELOG/CMake updated to 2.22.0.
This commit is contained in:
2026-09-15 19:32:02 +02:00
parent b3f7cad4db
commit 34970b961c
35 changed files with 2523 additions and 485 deletions
+145 -55
View File
@@ -6,6 +6,7 @@
#include <fcntl.h>
#include <libgen.h>
#include <limits.h>
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
@@ -72,6 +73,63 @@ static unsigned long long next_temp_sequence(void) {
return atomic_fetch_add_explicit(&sequence, 1, memory_order_relaxed);
}
/* Process-wide umask, captured exactly once. Reading the umask requires a
* get+set round trip (umask(0); umask(old)); doing that per write would be racy
* in the multithreaded receiver, so the value is captured at process startup by
* file_umask_capture() (called at the top of main(), before any threads exist).
* The pthread_once fallback keeps a caller that never called the capture (e.g. a
* unit test) correct. */
static unsigned g_process_umask;
static atomic_bool g_process_umask_captured;
static pthread_once_t g_process_umask_once = PTHREAD_ONCE_INIT;
static void file_capture_umask_now(void) {
mode_t mask = umask(0);
umask(mask);
g_process_umask = (unsigned)mask;
atomic_store_explicit(&g_process_umask_captured, true, memory_order_release);
}
static void file_capture_umask_once(void) {
if (atomic_load_explicit(&g_process_umask_captured, memory_order_acquire))
return;
file_capture_umask_now();
}
/* Re-captures the umask. Must only be called while the process is still
* single-threaded (startup, or the daemon's post-fork setup after umask(0)),
* so a later re-capture can refresh the cached value before any receiver
* thread exists. */
void file_umask_capture(void) {
file_capture_umask_now();
}
unsigned file_process_umask(void) {
if (!atomic_load_explicit(&g_process_umask_captured, memory_order_acquire))
pthread_once(&g_process_umask_once, file_capture_umask_once);
return g_process_umask;
}
/* Base mode applied when the policy does not take the source mode wholesale
* (i.e. --perms is off). A pre-existing destination keeps its own mode; a
* brand-new file is created like rsync: source_mode & 0777 & ~umask, with
* S_IWGRP|S_IWOTH always cleared so a client mode can never grant group/other
* write (the daemon runs with umask(0)). Only when no metadata is available at
* all does the historical fixed 0644 default apply. The -E rule (and no-op for
* a plain -t) is layered on top of this base. */
static mode_t file_mode_base(const FileMetadata* metadata, bool existing_known,
mode_t existing_mode) {
if (existing_known)
return existing_mode;
if (metadata)
/* A brand-new file follows rsync's source_mode & ~umask base, but a
* client-supplied source mode must never grant group/other write (the
* daemon runs with umask(0), so an unmasked 0666 would otherwise create a
* world-writable file). S_IWGRP|S_IWOTH are always cleared. */
return metadata->mode & 0777 & ~(mode_t)file_process_umask() & ~(S_IWGRP | S_IWOTH);
return S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH;
}
bool file_checksum(File* file, ChecksumAlgo algo, uint64_t seed, uint8_t* out, size_t out_capacity,
size_t* out_len) {
if (!file || !out || !out_len || !file->data)
@@ -861,7 +919,7 @@ int file_open_private_dir(const char* dir_path) {
* destination file) and best-effort: a per-attribute or privilege failure is
* logged and skipped, never fatal. */
static void restore_extra_fd(int fd, const FileMetadata* metadata, const FileXattrList* xattrs,
bool fake_super) {
bool fake_super, FileAttrPolicy policy) {
xattr_apply_fd(fd, xattrs);
if (fake_super && metadata) {
fake_super_store_fd(fd, (uint32_t)metadata->uid, (uint32_t)metadata->gid,
@@ -869,15 +927,16 @@ static void restore_extra_fd(int fd, const FileMetadata* metadata, const FileXat
/* Replay: re-apply the recorded uid/gid/mode/mtime fd-relative so a save
under --fake-super restores the attrs (when privileged) instead of only
recording them. Best-effort; fake_super_restore_fd silently skips a
non-root fchown EPERM/EACCES and never fatal. */
fake_super_restore_fd(fd);
non-root fchown EPERM/EACCES and never fatal. The replayed mode/mtime
honor the per-attribute policy so fake-super cannot bypass the split. */
fake_super_restore_fd(fd, policy);
}
}
static bool file_to_disk_secure_impl(const char* path, const void* data,
unsigned long long data_size, bool inplace, bool sparse,
bool preallocate, const FileMetadata* metadata,
bool preserve_executability, bool update, bool no_replace,
FileAttrPolicy policy, bool update, bool no_replace,
bool use_fsync, const char* temp_dir,
const FileXattrList* xattrs, bool fake_super,
bool keep_partial) {
@@ -887,6 +946,13 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
return false;
int fd = -1;
bool ok = false;
/* The base mode applied when --perms is off (neither the source mode nor an
* exec-only change is taken wholesale): a pre-existing destination keeps its
* own mode (special bits dropped), while a brand-new file uses
* source&~umask when metadata is available (see file_mode_base) or 0644 when
* there is none. Captured from the destination probe before the write. */
mode_t existing_mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH;
bool existing_mode_known = false;
if (inplace) {
/* --inplace writes directly into the destination; a scratch --temp-dir
does not apply and must never redirect these writes. */
@@ -897,10 +963,16 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
super-mode gate (a client-controlled device write). fstatat with
AT_SYMLINK_NOFOLLOW does not follow a symlink and does not block. */
struct stat pre_stat;
if (fstatat(dirfd, leaf, &pre_stat, AT_SYMLINK_NOFOLLOW) == 0 && !S_ISREG(pre_stat.st_mode)) {
close(dirfd);
free(leaf);
return false;
if (fstatat(dirfd, leaf, &pre_stat, AT_SYMLINK_NOFOLLOW) == 0) {
if (!S_ISREG(pre_stat.st_mode)) {
close(dirfd);
free(leaf);
return false;
}
/* Capture the old destination mode before the overwrite so a no--p/-E
* write can restore it (the write itself may clear setuid/setgid). */
existing_mode = pre_stat.st_mode & 0777;
existing_mode_known = true;
}
/* O_NONBLOCK: a no-op for a regular file, but a raced-in FIFO cannot block
the open before the post-open S_ISREG re-check rejects it. */
@@ -953,15 +1025,25 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
/* Normalize the mode: apply the metadata-derived safe mode when the
sender supplied metadata (setuid/setgid/sticky are never honored);
otherwise fall back to a safe default so dangerous bits on an
existing destination cannot survive an overwrite. */
existing destination cannot survive an overwrite. When the policy
requests neither -p nor -E the source mode is deliberately ignored
and the pre-existing destination mode (or 0644 for a new file) is
restored instead. The exec-bits-only -E change is likewise applied
on top of that destination-derived base, not the scratch file's
0600. */
if (ok) {
if (metadata)
ok = file_restore_metadata_fd(fd, metadata, preserve_executability);
else if (fchmod(fd, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) != 0)
if (metadata) {
if (!policy.perms &&
fchmod(fd, file_mode_base(metadata, existing_mode_known, existing_mode)) != 0)
ok = false;
if (ok)
ok = file_restore_metadata_fd(fd, metadata, policy);
} else if (fchmod(fd, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) != 0) {
ok = false;
}
}
if (ok)
restore_extra_fd(fd, metadata, xattrs, fake_super);
restore_extra_fd(fd, metadata, xattrs, fake_super, policy);
if (ok && use_fsync)
ok = fsync(fd) == 0;
}
@@ -974,16 +1056,21 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
install failure (partial data may exist, --partial may retain it) from a
pre-write validation failure (nothing to retain). */
bool write_attempted = false;
if (update && metadata) {
/* This check protects the normal atomic path as far as possible. A
concurrent replacement can still occur before the final rename. */
struct stat destination_stat;
if (fstatat(dirfd, leaf, &destination_stat, AT_SYMLINK_NOFOLLOW) == 0 &&
S_ISREG(destination_stat.st_mode) && stat_is_newer(&destination_stat, metadata)) {
close(dirfd);
free(leaf);
return true;
}
/* Probe the destination ONCE up front: it both drives the --update check
and records the pre-existing mode the no--p/-E fallback preserves. */
struct stat destination_stat;
bool destination_is_regular =
fstatat(dirfd, leaf, &destination_stat, AT_SYMLINK_NOFOLLOW) == 0 &&
S_ISREG(destination_stat.st_mode);
if (destination_is_regular) {
existing_mode = destination_stat.st_mode & 0777;
existing_mode_known = true;
}
if (update && metadata && destination_is_regular &&
stat_is_newer(&destination_stat, metadata)) {
close(dirfd);
free(leaf);
return true;
}
/* Scratch directory for the temporary working copy. When NULL the temp
file is created in the destination directory, exactly as historically. */
@@ -1061,10 +1148,19 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
? file_store_write_sparse(fd, (const unsigned char*)data, data_size)
: write_all(fd, data, data_size);
}
if (ok && metadata)
ok = file_restore_metadata_fd(fd, metadata, preserve_executability);
if (ok) {
if (metadata) {
if (!policy.perms &&
fchmod(fd, file_mode_base(metadata, existing_mode_known, existing_mode)) != 0)
ok = false;
if (ok)
ok = file_restore_metadata_fd(fd, metadata, policy);
} else if (fchmod(fd, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) != 0) {
ok = false;
}
}
if (ok)
restore_extra_fd(fd, metadata, xattrs, fake_super);
restore_extra_fd(fd, metadata, xattrs, fake_super, policy);
if (ok && use_fsync)
ok = fsync(fd) == 0;
}
@@ -1129,38 +1225,33 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
bool file_to_disk_secure(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse, bool preallocate, const FileMetadata* metadata,
bool preserve_executability, const char* temp_dir) {
FileAttrPolicy policy, const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
preserve_executability, false, false, false, temp_dir, NULL,
false, false);
policy, false, false, false, temp_dir, NULL, false, false);
}
bool file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse, bool preallocate,
const FileMetadata* metadata, bool preserve_executability,
const FileMetadata* metadata, FileAttrPolicy policy,
const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
preserve_executability, true, false, false, temp_dir, NULL, false,
false);
policy, true, false, false, temp_dir, NULL, false, false);
}
bool file_to_disk_secure_with_fsync(const char* path, const void* data,
unsigned long long data_size, bool inplace, bool sparse,
bool preallocate, const FileMetadata* metadata,
bool preserve_executability, 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,
preserve_executability, false, false, use_fsync, temp_dir, NULL,
false, false);
policy, false, false, use_fsync, temp_dir, NULL, false, false);
}
bool file_to_disk_secure_no_replace(const char* path, const void* data,
unsigned long long data_size, bool sparse, bool preallocate,
const FileMetadata* metadata, bool preserve_executability,
const FileMetadata* metadata, FileAttrPolicy policy,
const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, false, sparse, preallocate, metadata,
preserve_executability, false, true, false, temp_dir, NULL, false,
false);
policy, false, true, false, temp_dir, NULL, false, false);
}
/* Receiver write-path variant that also applies the per-file xattrs (-X/-A)
@@ -1170,13 +1261,12 @@ bool file_to_disk_secure_no_replace(const char* path, const void* data,
* failed write's temp. See file_to_disk_secure_impl for the semantics. */
bool file_to_disk_secure_attrs(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse, bool preallocate,
const FileMetadata* metadata, bool preserve_executability,
bool update, bool no_replace, bool use_fsync,
const FileXattrList* xattrs, bool fake_super, bool keep_partial,
const char* temp_dir) {
const FileMetadata* metadata, FileAttrPolicy policy, bool update,
bool no_replace, bool use_fsync, const FileXattrList* xattrs,
bool fake_super, bool keep_partial, const char* temp_dir) {
return file_to_disk_secure_impl(path, data, data_size, inplace, sparse, preallocate, metadata,
preserve_executability, update, no_replace, use_fsync, temp_dir,
xattrs, fake_super, keep_partial);
policy, update, no_replace, use_fsync, temp_dir, xattrs,
fake_super, keep_partial);
}
/* Atomic --link-dest install. The destination is replaced (via a temporary
@@ -1197,7 +1287,7 @@ bool file_to_disk_secure_attrs(const char* path, const void* data, unsigned long
static bool file_to_disk_secure_link_impl(const char* path, const char* basis_path,
const void* data, unsigned long long data_size,
bool preallocate, const FileMetadata* metadata,
bool preserve_executability, bool use_fsync,
FileAttrPolicy policy, bool use_fsync,
const FileXattrList* xattrs, bool fake_super,
const char* temp_dir) {
if (!path || !basis_path)
@@ -1286,8 +1376,8 @@ static bool file_to_disk_secure_link_impl(const char* path, const char* basis_pa
/* The basis file could not be linked in (missing, cross-device, refused
by the filesystem). Write a byte-identical local copy instead. */
return file_to_disk_secure_attrs(path, data, data_size, false, false, preallocate, metadata,
preserve_executability, false, false, use_fsync, xattrs,
fake_super, false, temp_dir);
policy, false, false, use_fsync, xattrs, fake_super, false,
temp_dir);
}
if (scratch_dirfd >= 0)
@@ -1299,25 +1389,25 @@ static bool file_to_disk_secure_link_impl(const char* path, const char* basis_pa
bool file_to_disk_secure_link(const char* path, const char* basis_path, const void* data,
unsigned long long data_size, bool preallocate,
const FileMetadata* metadata, bool preserve_executability,
bool use_fsync, const char* temp_dir) {
const FileMetadata* metadata, FileAttrPolicy policy, bool use_fsync,
const char* temp_dir) {
return file_to_disk_secure_link_impl(path, basis_path, data, data_size, preallocate, metadata,
preserve_executability, use_fsync, NULL, false, temp_dir);
policy, use_fsync, NULL, false, temp_dir);
}
bool file_to_disk_secure_link_attrs(const char* path, const char* basis_path, const void* data,
unsigned long long data_size, bool preallocate,
const FileMetadata* metadata, bool preserve_executability,
const FileMetadata* metadata, FileAttrPolicy policy,
bool use_fsync, const FileXattrList* xattrs, bool fake_super,
const char* temp_dir) {
return file_to_disk_secure_link_impl(path, basis_path, data, data_size, preallocate, metadata,
preserve_executability, use_fsync, xattrs, fake_super,
temp_dir);
policy, use_fsync, xattrs, fake_super, temp_dir);
}
bool file_write_to_disk(const char* path, const void* data, unsigned long long data_size,
bool inplace, bool sparse) {
if (!path || (!data && data_size != 0) || has_path_traversal(path))
return false;
return file_to_disk_secure(path, data, data_size, inplace, sparse, false, NULL, false, NULL);
FileAttrPolicy policy = {false, false, false, false};
return file_to_disk_secure(path, data, data_size, inplace, sparse, false, NULL, policy, NULL);
}