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
+112 -47
View File
@@ -52,7 +52,7 @@ static FileSaveResult file_stage_delayed_update(const char* root_directory,
if (!config)
return FILE_SAVE_ERROR;
bool sparse = config->preserve_sparse;
bool preserve_executability = config->use_executability;
FileAttrPolicy policy = file_attr_policy_from_config(config);
if (config->existing && !file_path_exists_secure(destination_path))
return FILE_SAVE_SKIPPED;
@@ -91,13 +91,12 @@ static FileSaveResult file_stage_delayed_update(const char* root_directory,
bool ok;
if (file->basis_link) {
ok = file_to_disk_secure_link(staged_path, file->basis_link, file->data->data, file->data->size,
config->preallocate, metadata, preserve_executability,
config->use_fsync, NULL);
config->preallocate, metadata, policy, config->use_fsync, NULL);
} else {
ok = file_to_disk_secure_attrs(staged_path, file->data->data, file->data->size, false, sparse,
config->preallocate, metadata, preserve_executability, false,
false, config->use_fsync, file->xattrs, config->fake_super,
false, NULL);
ok =
file_to_disk_secure_attrs(staged_path, file->data->data, file->data->size, false, sparse,
config->preallocate, metadata, policy, false, false,
config->use_fsync, file->xattrs, config->fake_super, false, NULL);
}
if (!ok) {
free(staged_path);
@@ -229,7 +228,7 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
}
bool preallocate = cfg && cfg->preallocate;
bool preserve_executability = cfg && cfg->use_executability;
FileAttrPolicy policy = file_attr_policy_from_config(cfg);
bool use_fsync = cfg && cfg->use_fsync;
if (cfg->delay_updates) {
@@ -265,9 +264,9 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
}
FileXattrList* sibling_xattrs =
cfg->use_xattrs ? xattr_capture_path(staged_first, cfg->preserve_acls) : NULL;
bool ok = file_to_disk_secure_link_attrs(
staged_sibling, staged_first, content, content_size, preallocate, file->metadata,
preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, NULL);
bool ok = file_to_disk_secure_link_attrs(staged_sibling, staged_first, content, content_size,
preallocate, file->metadata, policy, use_fsync,
sibling_xattrs, cfg ? cfg->fake_super : false, NULL);
xattr_list_free(sibling_xattrs);
free(content);
if (ok)
@@ -298,9 +297,9 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
const char* temp_dir = (cfg && cfg->temp_dir) ? cfg->temp_dir : NULL;
FileXattrList* sibling_xattrs =
cfg->use_xattrs ? xattr_capture_path(first_disk, cfg->preserve_acls) : NULL;
bool ok = file_to_disk_secure_link_attrs(
destination_path, first_disk, content, content_size, preallocate, file->metadata,
preserve_executability, use_fsync, sibling_xattrs, cfg ? cfg->fake_super : false, temp_dir);
bool ok = file_to_disk_secure_link_attrs(destination_path, first_disk, content, content_size,
preallocate, file->metadata, policy, use_fsync,
sibling_xattrs, cfg ? cfg->fake_super : false, temp_dir);
xattr_list_free(sibling_xattrs);
free(content);
free(first_disk);
@@ -437,7 +436,10 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
} else {
create_mode = S_IFIFO;
}
mode_t perms = mode & 0777;
/* The creation permission bits come from the source only under -p/--perms;
* otherwise a safe default (0644, group/other write never granted) keeps an
* unprivileged no--p run from materializing a world-writable node. */
mode_t perms = config->preserve_perms ? (mode & 0777 & ~(S_IWGRP | S_IWOTH)) : 0644;
int rc = is_fifo ? mkfifoat(parent_fd, leaf, perms)
: mknodat(parent_fd, leaf, create_mode | perms, rdev);
@@ -481,11 +483,23 @@ static FileSaveResult file_save_special_to_disk(const char* root_directory, cons
return FILE_SAVE_SKIPPED;
}
/* Apply mtime on the fresh node (utimensat, no-follow). */
struct timespec times[2] = {
{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = file->metadata->mtime_sec, .tv_nsec = file->metadata->mtime_nsec}};
utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW);
/* Apply times on the fresh node (utimensat, no-follow) per the negotiated
* per-attribute policy: mtime only under -t, atime only under -U. The slot
* not requested stays UTIME_OMIT so it is left untouched. */
FileAttrPolicy policy = file_attr_policy_from_config(config);
if (policy.times || (policy.atimes && file->metadata->atime_valid)) {
struct timespec times[2] = {{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = 0, .tv_nsec = UTIME_OMIT}};
if (policy.times) {
times[1].tv_sec = file->metadata->mtime_sec;
times[1].tv_nsec = file->metadata->mtime_nsec;
}
if (policy.atimes && file->metadata->atime_valid) {
times[0].tv_sec = file->metadata->atime_sec;
times[0].tv_nsec = file->metadata->atime_nsec;
}
utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW);
}
/* P7 Wave E: apply the negotiated ownership to the node ITSELF. A FIFO is
created unprivileged, but --copy-as and explicit identity policies own
every entry (a char/block node path is already privilege-gated above). The
@@ -592,7 +606,7 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
bool backup_enabled = config && config->backup && !config->ignore_existing;
bool inplace = config && config->inplace;
bool sparse = config && config->preserve_sparse;
bool preserve_executability = config && config->use_executability;
FileAttrPolicy policy = file_attr_policy_from_config(config);
const char* backup_suffix = (config && config->suffix) ? config->suffix : "~";
const char* backup_dir = (config && config->backup_dir) ? config->backup_dir : NULL;
const char* partial_dir = (config && config->partial_dir) ? config->partial_dir : NULL;
@@ -734,8 +748,11 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
(utimensat/lchown/fchmodat AT_SYMLINK_NOFOLLOW). -J/--omit-link-times
suppresses the timestamps; ownership stays gated by the identity policy.
A symlink has no children, so this can be applied immediately. */
if (ok && config && config->use_metadata)
ok = file_restore_symlink_metadata(link_path, file->metadata, config->omit_link_times);
if (ok && config && config->use_metadata) {
FileAttrPolicy link_policy = file_attr_policy_from_config(config);
ok = file_restore_symlink_metadata(link_path, file->metadata, link_policy,
config->omit_link_times);
}
free(link_path);
return ok ? FILE_SAVE_WRITTEN : FILE_SAVE_ERROR;
}
@@ -904,16 +921,15 @@ FileSaveResult file_save_to_disk_full(const char* root_directory, const File* fi
policy decision. */
bool ok;
if (config && file->basis_link) {
ok = file_to_disk_secure_link_attrs(disk_path, file->basis_link, file->data->data,
file->data->size, config->preallocate, metadata,
preserve_executability, config->use_fsync, file->xattrs,
config->fake_super, confined_temp);
ok = file_to_disk_secure_link_attrs(
disk_path, file->basis_link, file->data->data, file->data->size, config->preallocate,
metadata, policy, config->use_fsync, file->xattrs, config->fake_super, confined_temp);
} else {
/* The plain no-replace / update / with-fsync engines, plus per-file xattr
(-X/-A) and --fake-super application on the written fd. */
ok = file_to_disk_secure_attrs(
disk_path, file->data->data, file->data->size, inplace, sparse,
config && config->preallocate, metadata, preserve_executability, config && config->update,
config && config->preallocate, metadata, policy, config && config->update,
config && config->ignore_existing, config && config->use_fsync, file->xattrs,
config ? config->fake_super : false, config ? config->partial : false, confined_temp);
}
@@ -2401,10 +2417,15 @@ File* file_receive(const Config* config, int file_descriptor) {
return file;
}
/* ---- P7 Wave D: deferred directory times ---- */
/* ---- P7 Wave D: deferred directory metadata ---- */
bool dir_times_should_capture(const Config* config) {
return config->use_metadata && !config->omit_dir_times;
bool dir_metadata_should_capture(const Config* config) {
/* Directory metadata is captured when a directory attribute is actually
* requested: -p/--perms (directory modes) or -t/--times (directory mtimes,
* unless -O/--omit-dir-times suppresses them). --atimes/-U alone does not
* pull directory metadata (matching the original dir-time bundle). */
return config && config->use_metadata &&
(config->preserve_perms || (config->preserve_times && !config->omit_dir_times));
}
void dir_time_list_init(DirTimeList* list) {
@@ -2475,8 +2496,13 @@ bool dir_time_list_add(DirTimeList* list, const char* wire_path, const FileMetad
return true;
}
void dir_time_list_apply(const DirTimeList* list, const char* root_directory) {
if (!list || !root_directory)
void dir_metadata_list_apply(const DirTimeList* list, const char* root_directory,
const Config* config) {
if (!list || !root_directory || !config)
return;
bool apply_times = config->preserve_times && !config->omit_dir_times;
bool apply_mode = config->preserve_perms;
if (!apply_times && !apply_mode)
return;
for (size_t i = 0; i < list->count; i++) {
char* dir_path = path_cat(root_directory, list->paths[i]);
@@ -2484,7 +2510,8 @@ void dir_time_list_apply(const DirTimeList* list, const char* root_directory) {
continue;
char* leaf = NULL;
/* The parent walk is fd-relative and O_NOFOLLOW, so a symlink planted in a
parent component can never redirect the utimensat outside the root. */
parent component can never redirect the utimensat/chmod outside the
root. */
int parent_fd = file_open_secure_parent(dir_path, &leaf, false);
if (parent_fd < 0) {
free(dir_path);
@@ -2493,8 +2520,8 @@ void dir_time_list_apply(const DirTimeList* list, const char* root_directory) {
/* A dir-time entry only records metadata: the directory is (deliberately)
not created from it, so an empty source directory (or one pruned by
-m/--prune-empty-dirs) may well not exist here. Skip absent paths
QUIETLY rather than warning for every one, and apply the times only to a
real directory that does exist. AT_SYMLINK_NOFOLLOW keeps a same-named
QUIETLY rather than warning for every one, and apply the metadata only to
a real directory that does exist. AT_SYMLINK_NOFOLLOW keeps a same-named
symlink from being followed; a pre-existing regular file/symlink is not a
directory, so it is left completely untouched. */
struct stat st;
@@ -2504,18 +2531,56 @@ void dir_time_list_apply(const DirTimeList* list, const char* root_directory) {
free(dir_path);
continue;
}
struct timespec times[2] = {
{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = list->entries[i].mtime_sec, .tv_nsec = list->entries[i].mtime_nsec}};
if (list->entries[i].atime_valid) {
times[0].tv_sec = list->entries[i].atime_sec;
times[0].tv_nsec = list->entries[i].atime_nsec;
if (apply_times) {
struct timespec times[2] = {
{.tv_sec = 0, .tv_nsec = UTIME_OMIT},
{.tv_sec = list->entries[i].mtime_sec, .tv_nsec = list->entries[i].mtime_nsec}};
if (config->preserve_atimes && list->entries[i].atime_valid) {
times[0].tv_sec = list->entries[i].atime_sec;
times[0].tv_nsec = list->entries[i].atime_nsec;
}
if (utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW) != 0) {
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Failed to set directory timestamps on %s: %s",
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
free(escaped_path);
}
}
if (utimensat(parent_fd, leaf, times, AT_SYMLINK_NOFOLLOW) != 0) {
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Failed to set directory timestamps on %s: %s",
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
free(escaped_path);
if (apply_mode) {
mode_t dir_mode = list->entries[i].mode;
bool mode_ready = true;
if (config->chmod_spec && *config->chmod_spec &&
!chmod_apply(dir_mode, config->chmod_spec, &dir_mode)) {
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Failed to apply --chmod to directory %s",
escaped_path ? escaped_path : "<allocation failed>");
free(escaped_path);
mode_ready = false;
}
if (mode_ready) {
/* Route the directory mode through the SAME sanitization as the
* regular-file policy: a client-supplied mode never grants group/other
* write. Open the directory with O_DIRECTORY|O_NOFOLLOW (never
* following a same-named symlink) and fchmod the fd, avoiding the
* fchmodat(..., 0) TOCTOU/symlink-follow hole. */
mode_t safe_mode =
(dir_mode & 0777 & ~(S_IWGRP | S_IWOTH)) | (dir_mode & (S_ISGID | S_ISVTX));
int dir_fd = openat(parent_fd, leaf, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
if (dir_fd < 0) {
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Failed to open directory %s to set its mode: %s",
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
free(escaped_path);
} else {
if (fchmod(dir_fd, safe_mode) != 0) {
char* escaped_path = output_escape(dir_path, log_get_8_bit_output());
log_message(LOG_LEVEL_WARNING, "Failed to set directory mode on %s: %s",
escaped_path ? escaped_path : "<allocation failed>", strerror(errno));
free(escaped_path);
}
close(dir_fd);
}
}
}
close(parent_fd);
free(leaf);