Merge feat/p4-acl-xattr: -X/-A/--fake-super

# Conflicts:
#	src/shared/config.c
#	src/shared/file.c
#	src/shared/file_types.h
#	tests/integration/test_features.py
#	tests/test_client_cli.c
#	tests/test_config.c
This commit is contained in:
2026-09-08 22:38:15 +02:00
23 changed files with 1297 additions and 52 deletions
+51 -3
View File
@@ -244,8 +244,8 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
| `-t`, `--times` | Preserve modification times | ✅ Implemented | Part of -M |
| `-E`, `--executability` | Preserve executability | ✅ Implemented | Preserves executable permission bits (implies metadata preservation) |
| `--chmod=CHMOD` | Affect file permissions | ✅ Implemented | Supports numeric and symbolic `ugo` `rwx` changes; retains receiver safety masking |
| `-A`, `--acls` | Preserve ACLs | ❌ Not Implemented | Removed because it had no effect |
| `-X`, `--xattrs` | Preserve extended attributes | ❌ Not Implemented | Removed because it had no effect |
| `-A`, `--acls` | Preserve ACLs | ✅ Implemented | Implemented on Linux via the POSIX-ACL xattr representation: the sender captures the `system.posix_acl_access` / `system.posix_acl_default` xattrs into the same bounded whitelisted set as `-X`, transmits them per-file, and the receiver re-applies them fd-relative. Setting an ACL the receiver is not permitted to set (non-root on a file it does not own, unsupported filesystem) is logged and skipped, never fatal. libacl is **not** required. Only the `system.posix_acl_*` namespaces plus `user.*` are ever applied; privileged namespaces are never applied (see the Phase-4 xattr/ACL notes below). Implies metadata transmission |
| `-X`, `--xattrs` | Preserve extended attributes | ✅ Implemented | Preserves unprivileged `user.*` extended attributes (Linux `listxattr`/`getxattr` on capture, `fsetxattr` on the written destination fd). Both capture (sender) and application (receiver) are restricted to the `user.*` namespace and the two POSIX ACL xattrs, so a client can **never** force a `security.*`/`trusted.*`/privileged attribute onto the destination; the receiver independently re-validates every incoming name against this whitelist and rejects anything else. Payloads are bounded (per-name ≤255B, per-value ≤1MiB, per-file count ≤256 total bytes ≤4MiB) on both ends, and an oversized/malformed frame is a clean protocol rejection (no OOM). Applied fd-relative to the exact written file. Implies metadata transmission. Incompatible with `-s` (chunk serialization), rejected up front (see the notes); a `--link-dest`/`-H` hard-link copy fallback re-applies the attributes so they are not dropped when a link is refused |
| `-H`, `--hard-links` | Preserve hard links | ✅ Implemented | Files on the source that share an inode (`st_dev`+`st_ino`, e.g. a `cp -al` tree) are re-created as hard links to one another on the destination, so duplicate links stay deduplicated and only the first member's data is sent (later members are transmitted as payload-less `STATUS_HARDLINK` frames). The receiver links each sibling to the first member's installed file with an atomic link + rename; on `link()` failure it falls back to a byte-identical local copy of the first member, never a partial/corrupt file. Requires the sequential scan for ordering (the first member is always emitted and installed before any sibling is linked). Works single-threaded and under `-m`, `--inplace`, `--delay-updates` (links staged and published by rename) and `--partial`. Crosses the wire (`preserve_hard_links` bool; `PROTOCOL_VERSION` bumped **2.11.0 → 2.12.0**, peers must match). Incompatible with `-s` (chunk serialization) and `--append`/`--append-verify`, rejected up front with a distinct error. See the Phase-4 hard-links notes below |
| `-D` | Same as --devices --specials | ✅ Implemented | Implies `--devices --specials`. `-D` was unassigned in FastSync (verified: no collision), so it is free to imply both device-node and special-file preservation. See the `--devices`/`--specials` rows and the Phase-4 devices notes below |
| `--devices` | Preserve device files | ⚠️ Partial | Recreates char/block device nodes on the destination via `mknod` instead of transferring content. Type + rdev are validated strictly (S_IFMT from the transmitted mode; major/minor range-checked, non-negative), and creation is **privilege-gated**: `mknod` needs `CAP_MKNOD`, so a non-root receiver (CI runs via setpriv as non-root) logs a warning and **skips the device entry safely** — the whole transfer never aborts just because the node could not be made. The node is created fd-relative below the receive root (`mknodat` on the confined secure parent), so it can never be placed outside the authorized root, never follows a symlink, and never replaces an existing directory. Only a char/block mode is honored. Crosses the wire (a new `STATUS_SPECIAL` frame carries the path + metadata mode + rdev; `PROTOCOL_VERSION` bumped **2.12.0 → 2.13.0**). Divergence: per-entry skip (not a hard error) when the receiver lacks `CAP_MKNOD`, documented in the Phase-4 devices notes |
@@ -257,7 +257,7 @@ why plain `--append` works on the normal atomic path, not only with `--inplace`.
| `-O`, `--omit-dir-times` | Omit dirs from --times | 🔄 Compatibility No-op | Accepted and parsed for CLI compatibility, and the config boolean crosses the wire, but it has **no effect**: FastSync never preserves directory mtimes in the first place (directories are created via `mkdir` with no metadata, a documented divergence under `-d`/recursive), so there is nothing for an "omit" to suppress. It never breaks a normal run |
| `-J`, `--omit-link-times` | Omit symlinks from --times | 🔄 Compatibility No-op | Accepted and parsed for CLI compatibility, and the config boolean crosses the wire, but it has **no effect**: FastSync never sets symlink times (`-l`/`--links` copies symlinks as symlinks but the receiver does not apply timestamps/owner to symlink entries), so there is nothing for an "omit" to suppress. It never breaks a normal run |
| `--super` | Receiver attempts super-user activities | ❌ Not Implemented | |
| `--fake-super` | Store/recover privileged attrs via xattrs | ❌ Not Implemented | |
| `--fake-super` | Store/recover privileged attrs via xattrs | ⚠️ Partial | Honest, limited subset. The receiver records the source `uid:gid:mode:mtime_sec:mtime_nsec` into a reserved `user.fastsync.stat` xattr on each written file (best-effort, fd-relative), so a later privileged restore could re-apply them — without attempting the (typically failing as non-root) `chown`. Full rsync fake-super **replay** (parsing that xattr to actually re-apply ownership on a later privileged run) is out of scope and is **divergent** from rsync, which uses its own `user.rsync.%stat%` format; no cross-tool conversion is attempted. Implies metadata transmission so the source uid/gid/mode/mtime are available. Both it and `-X`/`-A` are incompatible with `-s` (chunk serialization), rejected up front |
| `--open-noatime` | Avoid changing access time when opening files | ✅ Implemented | Sender-side policy: the sender opens source files with `O_NOATIME` (Linux) when reading them for transfer, so the open/read does NOT bump the source's on-disk access time. Degrades safely when `O_NOATIME` is unavailable (not defined) or refused (`EPERM`, since it needs `CAP_FOWNER` or file ownership): the code falls back to a normal open, so the data always transfers — only the atime-bump is skipped. It does not itself capture/preserve atime; it only avoids modifying it. **Client-only, never crosses the wire.** Exposed as `file_open_for_read()` and applied to both the buffered data path and the sendfile path |
| `--numeric-ids` | Do not map uid/gid by name | ✅ Implemented | Ownership is applied through FastSync's opt-in identity path (see the Phase-4 identity notes below). `--numeric-ids` is a mapping-policy modifier: when applying ownership it uses the transmitted numeric uid/gid directly, skipping the name lookup. Without an ownership-affecting option it is inert (FastSync only applies ownership when the user opts in). It does not need `-M` to be parsed, but ownership is only applied when metadata (hence the source uid/gid) is actually transmitted (see the notes) |
| `--usermap=STRING` | Map usernames | ✅ Implemented | Opt-in ownership application. rsync subset implemented: comma-separated `FROM:TO` rules evaluated in order, first match wins; `FROM`/`TO` are group/user names (resolved on the SOURCE machine at parse time), `*` (FROM matches any id / TO = the receiving process's current euid), and an `@N` or bare `N` numeric id. Rules are carried over the wire as resolved numeric id pairs; the receiver applies a matching rule (else falls back to `--chown`, `--numeric-ids`, then a best-effort name lookup) via an fd-relative `fchown`. Malformed/unresolvable specs are rejected with a clear error, never a silent no-op. Implies metadata preservation so the source uid/gid travel. Only effective when the receiver can actually change ownership (root or membership); otherwise it warns and continues |
@@ -284,6 +284,54 @@ receiver (apply), so they and their metadata fields cross the wire;
(mirroring the existing convention where `ignore_errors` is client-only while
`force_delete` crosses the wire).
**Phase-4 xattr/ACL notes (`-X/--xattrs`, `-A/--acls`, `--fake-super`):** these
are new in protocol 2.13.0 and add a bounded per-file xattr block to the
per-file metadata frame (count + each `name`/`value`, sent only when xattr
transport is enabled, i.e. with zero overhead on unaffected runs). The config
frame carries `preserve_xattrs`, `preserve_acls` (in the existing file-options
block) and a trailing `fake_super` boolean — all CROSS the wire so the receiver
knows the negotiated behavior; the derived `use_xattrs` flag is recomputed on
the receiver. `PROTOCOL_VERSION` was bumped **2.12.0 → 2.13.0** (peers must
match, exactly as prior phases did).
- **Security model (both `-X` and `-A`):** only `user.*` and the
`system.posix_acl_access` / `system.posix_acl_default` namespaces are ever
captured (sender) or applied (receiver). `security.*` (SELinux, capabilities,
...), `trusted.*`, and all other `system.*` attributes are never transmitted
or applied, so a client can never compel the receiver to set a privileged
xattr. The receiver re-validates each incoming name against this whitelist
even though the sender already filtered, so a malicious/compromised sender's
`security.capability` payload is rejected outright (a clean protocol error),
never applied.
- **Bounds / memory safety:** per-name length ≤ 255 B, per-value ≤ 1 MiB,
per-file count ≤ 256 names, per-file name+value total ≤ 4 MiB. Both the
sender (during capture) and the receiver (during receive) enforce these; an
oversized or malformed frame is rejected, never a large allocation.
- **Confined application:** xattrs are applied with `fsetxattr` on the exact
just-written destination file fd (before the atomic rename), never on a
caller-controlled path; this is the same confinement as mode/time restore.
The `--link-dest` / `-H` hard-link copy fallback (a byte copy when `link()`
is refused) also re-applies the incoming (or, for `-H`, the first member's)
xattrs and the `--fake-super` stat, so attributes are preserved rather than
silently dropped when the link fails.
- **Reserved fake-super key is receiver-only:** the `user.fastsync.stat` key is
excluded from sender capture AND from receiver application, so it can only be
written by the receiver's own `--fake-super` handling. A source file that
already carries such a record is never forwarded on a plain `-X` run, so it
cannot be spoofed to mislead a later privileged restore.
- **`-A` requires no libacl** — ACLs travel as the `system.posix_acl_*` xattrs.
Applying an ACL is owner-privileged: `fsetxattr` failure (e.g. non-root,
unsupported filesystem) is logged (collapsed to one line per file) and never
fatal.
- **`--fake-super`**: see the row above; the reserved key is `user.fastsync.stat`
with the documented `uid:gid:mode:mtime_sec:mtime_nsec` (mode octal) format.
It is honest but partial — there is no replay, and it does not interoperate
with rsync's `user.rsync.%stat%`.
- **Chunk serialization (`-s`) incompatibility:** the per-file xattr block rides
the streaming per-file frame, which `-s` replaces with a fixed buffer format,
so `-X` / `-A` combined with `-s` is rejected up front on both ends (mirroring
the existing `-H` + `-s` rejection) rather than silently dropping attributes.
**atime capture does not clobber the source atime:** the sender records the
access time from the **same pre-read stat the scanner already took** (inside
`file_metadata_create`), before any file data is read for transfer. So `-U`
+17
View File
@@ -548,6 +548,9 @@ static const OptionEntry OPTION_TABLE[] = {
{"--omit-dir-times", "-O", OPT_FLAG, offsetof(Config, omit_dir_times)},
{"--omit-link-times", "-J", OPT_FLAG, offsetof(Config, omit_link_times)},
{"--open-noatime", NULL, OPT_FLAG, offsetof(Config, open_noatime)},
{"--xattrs", "-X", OPT_FLAG, offsetof(Config, preserve_xattrs)},
{"--acls", "-A", OPT_FLAG, offsetof(Config, preserve_acls)},
{"--fake-super", NULL, OPT_FLAG, offsetof(Config, fake_super)},
};
/* Only boolean options with no required argument are safe to negate. */
@@ -582,6 +585,9 @@ static const NegatableOption NEGATABLE_OPTIONS[] = {
{"preserve", "M", offsetof(Config, use_metadata)},
{"sendfile", "f", offsetof(Config, use_sendfile)},
{"chunk-serialization", "s", offsetof(Config, use_chunk_serialization)},
{"xattrs", "X", offsetof(Config, preserve_xattrs)},
{"acls", "A", offsetof(Config, preserve_acls)},
{"fake-super", NULL, offsetof(Config, fake_super)},
};
static bool opt_is(const char* arg, const char* name, const char* alias) {
@@ -818,6 +824,13 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
if (entry->offset == offsetof(Config, preserve_atimes) ||
entry->offset == offsetof(Config, preserve_crtimes))
config->use_metadata = true;
if (entry->offset == offsetof(Config, preserve_xattrs) ||
entry->offset == offsetof(Config, preserve_acls)) {
config->use_metadata = true;
config->use_xattrs = config->preserve_acls || config->preserve_xattrs;
}
if (entry->offset == offsetof(Config, fake_super))
config->use_metadata = true;
continue;
}
@@ -1265,6 +1278,10 @@ int parse_args(Config* config, int argc, char* argv[], int* positional_args,
log_message(LOG_LEVEL_INFO, "Enabling metadata preservation for incremental/delta transfer");
config->use_metadata = true;
}
/* Recompute the derived xattr flag from the FINAL preserve flags (after any
* --no-xattrs/--no-acls negation) so the sender's wire gate always matches
* the flags the receiver will recompute from the received config. */
config->use_xattrs = config->preserve_acls || config->preserve_xattrs;
return 0;
}
+15 -5
View File
@@ -20,6 +20,7 @@
#include "transport_ssh.h"
#include "transport_tls.h"
#include "utils.h"
#include "xattr.h"
#include <fcntl.h>
#include <limits.h>
#include <stdio.h>
@@ -89,6 +90,8 @@ static bool prepare_scanner(const Config* config, int num_threads, PreparedScann
options->use_metadata = config->use_metadata;
options->preserve_atimes = config->preserve_atimes;
options->preserve_crtimes = config->preserve_crtimes;
options->preserve_xattrs = config->preserve_xattrs;
options->preserve_acls = config->preserve_acls;
options->chunk_size = config->chunk_size;
options->exclude_patterns = config->exclude_patterns;
options->exclude_count = config->exclude_count;
@@ -963,6 +966,9 @@ static int send_delta(Client* client, File* file, DeltaSignature* sig, Config* c
if (ok && config->use_metadata)
ok = metadata_send(client->file_descriptor, file->metadata);
if (ok && config->use_xattrs)
ok = xattr_send(client->file_descriptor, file->xattrs);
data_destroy(to_send);
return ok ? 0 : -1;
}
@@ -1004,7 +1010,7 @@ static int send_append(const Client* client, File* file, Config* config,
transfer (byte-identical, never a corrupt prefix+tail blend). */
int rc = file_send_single_calls_with_skip(file, fd, config->use_metadata, compression_level,
false, config->skip_compress_suffixes, skip_count,
config->compression_threads)
config->compression_threads, config->use_xattrs)
? 1
: -1;
return rc;
@@ -1021,6 +1027,9 @@ static int send_append(const Client* client, File* file, Config* config,
if (config->use_metadata && !metadata_send(fd, file->metadata)) {
return -1;
}
if (config->use_xattrs && !xattr_send(fd, file->xattrs)) {
return -1;
}
bool ok;
if (compress) {
/* Compression needs an owned copy of the tail to compress. */
@@ -1058,7 +1067,7 @@ static bool send_file_direct(File* file, int fd, bool use_metadata, int compress
int skip_count = config->skip_compress_set ? config->skip_compress_count : -1;
return file_send_single_calls_with_skip(file, fd, use_metadata, compression_level, true,
config->skip_compress_suffixes, skip_count,
config->compression_threads);
config->compression_threads, config->use_xattrs);
}
/* Transmit one explicit directory entry (--dirs): a STATUS_MKDIR frame whose
@@ -1093,7 +1102,7 @@ static bool send_file_direct_sendfile(File* file, int fd, bool use_metadata, con
int skip_count = config->skip_compress_set ? config->skip_compress_count : -1;
return file_send_sendfile_with_skip(file, fd, use_metadata, 0, true,
config->skip_compress_suffixes, skip_count,
config->compression_threads);
config->compression_threads, config->use_xattrs);
}
// Process one file in a chunk: either via incremental check or direct send.
@@ -1151,7 +1160,7 @@ static int send_single_file(Client* client, File* file, Config* config, bool use
int skip_count = config->skip_compress_set ? config->skip_compress_count : -1;
if (!file_send_sendfile_with_skip(file, client->file_descriptor, config->use_metadata, 0, false,
config->skip_compress_suffixes, skip_count,
config->compression_threads))
config->compression_threads, config->use_xattrs))
return -1;
return 0;
}
@@ -1200,7 +1209,8 @@ static int send_single_file(Client* client, File* file, Config* config, bool use
int skip_count = config->skip_compress_set ? config->skip_compress_count : -1;
if (!file_send_single_calls_with_skip(file, client->file_descriptor, config->use_metadata,
compression_level, false, config->skip_compress_suffixes,
skip_count, config->compression_threads))
skip_count, config->compression_threads,
config->use_xattrs))
return -1;
return 0;
}
+8
View File
@@ -79,6 +79,14 @@ bool validate_config(const Config* config) {
"--hard-links/-H cannot be combined with -s (chunk serialization)");
return false;
}
/* -X/-A ride the per-file metadata frame; the buffer-based chunk-serialization
wire format does not carry the xattr block, so the pair is rejected up front
(mirroring -H + -s) rather than silently dropping attributes. */
if ((config->preserve_xattrs || config->preserve_acls) && config->use_chunk_serialization) {
log_message(LOG_LEVEL_ERROR,
"--xattrs/-X and --acls/-A cannot be combined with -s (chunk serialization)");
return false;
}
if (config->preserve_hard_links && (config->append || config->append_verify)) {
log_message(LOG_LEVEL_ERROR,
"--hard-links/-H cannot be combined with --append/--append-verify");
+17
View File
@@ -15,6 +15,8 @@
#include <unistd.h>
#include <limits.h>
#include "xattr.h"
typedef struct {
char* path;
int depth;
@@ -172,6 +174,14 @@ static bool entry_passes_selection(const FileListSet* file_list, const FilterRul
return true;
}
/* Best-effort capture of the file's whitelisted xattrs (-X/-A). A failure to
* read xattrs is non-fatal: the file is transferred without them. */
static void scanner_capture_xattrs(const DirectoryScanner* scanner, File* file) {
if (!scanner || !file || !(scanner->preserve_xattrs || scanner->preserve_acls))
return;
file->xattrs = xattr_capture_path(file->path);
}
/* Apply --hard-links (-H) detection to one regular File. On a sibling (a
* later member of an already-seen source inode) the File keeps the group id
* and the first member's wire path but carries NO data payload (size 0); the
@@ -432,6 +442,8 @@ DirectoryScanner* directory_scanner_create_with_options(const char* root_directo
scanner->use_metadata = options->use_metadata;
scanner->preserve_atimes = options->preserve_atimes;
scanner->preserve_crtimes = options->preserve_crtimes;
scanner->preserve_xattrs = options->preserve_xattrs;
scanner->preserve_acls = options->preserve_acls;
scanner->chunk_size = options->chunk_size > 0 ? options->chunk_size : DESIRED_CHUNK_SIZE;
scanner->exclude_patterns = options->exclude_patterns;
scanner->exclude_count = options->exclude_count;
@@ -1036,6 +1048,8 @@ Chunk* directory_scanner_next(DirectoryScanner* scanner) {
scanner->failed = true;
break;
}
if (!(file->link_group != 0 && !file->link_first))
scanner_capture_xattrs(scanner, file);
if (!array_list_add(chunk_data, file)) {
free(rel_copy);
file_destroy(file);
@@ -1379,6 +1393,9 @@ static void scan_root_entry(const ScannerOptions* options, const FilterNode* roo
ps->failed = true;
return;
}
if ((options->preserve_xattrs || options->preserve_acls) &&
!(file->link_group != 0 && !file->link_first))
file->xattrs = xattr_capture_path(file->path);
if (!array_list_add(root_files, file)) {
free(rel);
file_destroy(file);
+6
View File
@@ -19,6 +19,10 @@ typedef struct {
* capture the source access / birth time into each entry's FileMetadata. */
bool preserve_atimes;
bool preserve_crtimes;
/* Phase 4 xattrs: when preserve_xattrs || preserve_acls is set the scanner
* captures each regular file's whitelisted xattr set onto the File. */
bool preserve_xattrs;
bool preserve_acls;
unsigned long long chunk_size;
char** exclude_patterns;
int exclude_count;
@@ -101,6 +105,8 @@ typedef struct {
bool use_metadata;
bool preserve_atimes;
bool preserve_crtimes;
bool preserve_xattrs;
bool preserve_acls;
unsigned long long chunk_size;
char** exclude_patterns;
int exclude_count;
+10
View File
@@ -128,6 +128,16 @@ void print_usage(void) {
printf(" none suppresses info even with --verbose\n");
printf(" -M, --preserve Preserve file metadata\n");
printf(" -E, --executability Preserve executable permission bits\n");
printf(" -X, --xattrs Preserve user extended attributes (user.* only;\n");
printf(" privileged security.*/trusted.* namespaces are\n");
printf(" never captured or applied)\n");
printf(" -A, --acls Preserve POSIX ACLs (the system.posix_acl_* xattrs;\n");
printf(" setting an ACL the receiver is not permitted to\n");
printf(" set is warned and skipped, never fatal)\n");
printf(" --fake-super Store the source uid/gid/mode/mtime in a reserved\n");
printf(" user.fastsync.stat xattr on each written file instead\n");
printf(" of applying ownership (for a later privileged restore);\n");
printf(" partial: full rsync fake-super replay is out of scope\n");
printf(" --chmod <changes> Modify transferred permissions (rsync syntax)\n");
printf(" --numeric-ids Do not map uid/gid by name: use the source numeric\n");
printf(" ids directly when applying ownership\n");
+24 -2
View File
@@ -159,6 +159,8 @@ static void config_set_defaults(Config* config) {
config->omit_dir_times = false;
config->omit_link_times = false;
config->open_noatime = false;
config->use_xattrs = false;
config->fake_super = false;
}
static bool valid_wire_bool(int value) {
@@ -210,9 +212,12 @@ static bool validate_received_config(const Config* config) {
!((config->append || config->append_verify) && config->use_chunk_serialization) &&
!(config->preserve_hard_links && config->use_chunk_serialization) &&
!(config->preserve_hard_links && (config->append || config->append_verify)) &&
/* The xattr block rides the per-file streaming frame, which -s drops. */
!((config->preserve_xattrs || config->preserve_acls) && config->use_chunk_serialization) &&
valid_wire_bool(config->preserve_atimes) && valid_wire_bool(config->preserve_crtimes) &&
valid_wire_bool(config->omit_dir_times) && valid_wire_bool(config->omit_link_times) &&
valid_wire_bool(config->munge_links) && valid_wire_bool(config->keep_dirlinks) &&
valid_wire_bool(config->fake_super) &&
(!config->use_compression ||
(config->compression_level >= 1 && config->compression_level <= 22)) &&
config->chunk_size > 0 && config->chunk_size <= MAX_CHUNK_SIZE &&
@@ -797,6 +802,21 @@ static bool receive_symlink_trust_options(int fd, Config* c) {
return receive_wire_bool(fd, &c->munge_links) && receive_wire_bool(fd, &c->keep_dirlinks);
}
/* -X/--xattrs, -A/--acls, --fake-super (Phase-4). The receiver learns
* preserve_xattrs/preserve_acls from the earlier file-options block and
* recomputes the derived use_xattrs there; only --fake-super (receiver-side
* behavior) needs an extra wire bit. Trailing field; protocol 2.13.0. */
static bool send_phase4_xattr_options(int fd, const Config* c) {
return send_int(fd, c->fake_super);
}
static bool receive_phase4_xattr_options(int fd, Config* c) {
if (!receive_wire_bool(fd, &c->fake_super))
return false;
c->use_xattrs = c->preserve_acls || c->preserve_xattrs;
return true;
}
bool config_send(int file_descriptor, const Config* config) {
protocol_session_set_max_alloc(NULL, config->max_alloc);
if (!send_core_fields(file_descriptor, config) || !send_delta_fields(file_descriptor, config) ||
@@ -807,7 +827,8 @@ bool config_send(int file_descriptor, const Config* config) {
!send_checksum_options(file_descriptor, config) ||
!send_identity_options(file_descriptor, config) ||
!send_metadata_times_options(file_descriptor, config) ||
!send_symlink_trust_options(file_descriptor, config))
!send_symlink_trust_options(file_descriptor, config) ||
!send_phase4_xattr_options(file_descriptor, config))
return false;
Status status;
if (!receive_status(file_descriptor, &status))
@@ -845,7 +866,8 @@ Config* config_receive(int file_descriptor) {
!receive_checksum_options(file_descriptor, config) ||
!receive_identity_options(file_descriptor, config) ||
!receive_metadata_times_options(file_descriptor, config) ||
!receive_symlink_trust_options(file_descriptor, config))
!receive_symlink_trust_options(file_descriptor, config) ||
!receive_phase4_xattr_options(file_descriptor, config))
goto error;
if (config->compress_choice[0] != '\0' && strcmp(config->compress_choice, "zstd") != 0 &&
strcmp(config->compress_choice, "none") != 0) {
+15
View File
@@ -335,6 +335,21 @@ typedef struct Config {
* source files with O_NOATIME so reading for transfer does not bump the
* source access time. */
bool open_noatime;
// Phase 4: xattr / ACL / fake-super preservation.
/* -X/--xattrs and -A/--acls toggle the sender's capture and the receiver's
* application of per-file extended attributes (xattrs). Both cross the wire:
* the sender only transmits the bounded, whitelisted attribute set it
* captures and the receiver re-validates namespaces/sizes before applying
* fd-relative. With neither set (the default) no xattr block is sent, so the
* wire is byte-identical to prior protocol versions for unaffected runs. */
/* true when preserve_xattrs || preserve_acls; the sender/receiver gate the
* xattr wire block on this single flag. */
bool use_xattrs;
/* --fake-super: receiver-only. When set, each written file additionally gets
* a reserved user.fastsync.stat xattr recording the source uid/gid/mode/mtime
* so a later privileged restore could re-apply them. Crosses the wire. */
bool fake_super;
} Config;
#define PROTOCOL_VERSION "2.13.0"
+78 -11
View File
@@ -20,6 +20,7 @@
#include "metadata.h"
#include "utils.h"
#include "protocol.h"
#include "xattr.h"
static bool write_all(int fd, const void* data, unsigned long long size) {
const unsigned char* p = data;
@@ -119,6 +120,7 @@ File* file_create(const char* path) {
file->is_special = false;
file->rdev_major = 0;
file->rdev_minor = 0;
file->xattrs = NULL;
return file;
}
@@ -140,6 +142,8 @@ void file_destroy(void* item) {
file->hardlink_target = NULL;
free(file->symlink_target);
file->symlink_target = NULL;
xattr_list_free(file->xattrs);
file->xattrs = NULL;
free(file);
}
@@ -767,11 +771,25 @@ int file_open_private_dir(const char* dir_path) {
return fd;
}
/* After the content and mode/times are restored on the just-written file, apply
* the per-file xattrs (-X/-A) and, for --fake-super, park the source's
* uid/gid/mode/mtime in the reserved xattr. All fd-relative (confined to the
* 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) {
xattr_apply_fd(fd, xattrs);
if (fake_super && metadata)
fake_super_store_fd(fd, (uint32_t)metadata->uid, (uint32_t)metadata->gid,
(uint32_t)metadata->mode, metadata->mtime_sec, metadata->mtime_nsec);
}
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,
bool use_fsync, const char* temp_dir) {
bool use_fsync, const char* temp_dir,
const FileXattrList* xattrs, bool fake_super) {
char* leaf = NULL;
int dirfd = file_open_secure_parent(path, &leaf, true);
if (dirfd < 0)
@@ -821,6 +839,8 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
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);
if (ok && use_fsync)
ok = fsync(fd) == 0;
}
@@ -908,6 +928,8 @@ static bool file_to_disk_secure_impl(const char* path, const void* data,
ok = write_all(fd, data, data_size);
if (ok && metadata)
ok = file_restore_metadata_fd(fd, metadata, preserve_executability);
if (ok)
restore_extra_fd(fd, metadata, xattrs, fake_super);
if (ok && use_fsync)
ok = fsync(fd) == 0;
}
@@ -961,7 +983,8 @@ bool file_to_disk_secure(const char* path, const void* data, unsigned long long
bool inplace, bool sparse, bool preallocate, const FileMetadata* metadata,
bool preserve_executability, 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);
preserve_executability, false, false, false, temp_dir, NULL,
false);
}
bool file_to_disk_secure_update(const char* path, const void* data, unsigned long long data_size,
@@ -969,7 +992,8 @@ bool file_to_disk_secure_update(const char* path, const void* data, unsigned lon
const FileMetadata* metadata, bool preserve_executability,
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);
preserve_executability, true, false, false, temp_dir, NULL,
false);
}
bool file_to_disk_secure_with_fsync(const char* path, const void* data,
@@ -978,7 +1002,8 @@ bool file_to_disk_secure_with_fsync(const char* path, const void* data,
bool preserve_executability, 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);
preserve_executability, false, false, use_fsync, temp_dir, NULL,
false);
}
bool file_to_disk_secure_no_replace(const char* path, const void* data,
@@ -986,7 +1011,22 @@ bool file_to_disk_secure_no_replace(const char* path, const void* data,
const FileMetadata* metadata, bool preserve_executability,
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);
preserve_executability, false, true, false, temp_dir, NULL,
false);
}
/* Receiver write-path variant that also applies the per-file xattrs (-X/-A)
* and, under --fake-super, parks the source stat in the reserved xattr, on the
* just-written file descriptor before the final rename. `no_replace` / `update`
* mirror the plain wrappers; 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, 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);
}
/* Atomic --link-dest install. The destination is replaced (via a temporary
@@ -998,10 +1038,18 @@ bool file_to_disk_secure_no_replace(const char* path, const void* data,
* fallback; a successful hard link keeps the basis inode's own attributes
* (applying metadata through the shared inode would mutate the basis file).
* Returns false only when both the link and the copy fallback fail. */
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) {
/* --link-dest / -H hardlink install with a byte-copy fallback. `metadata` is
* applied only on the copy fallback; a successful hard link keeps the basis
* inode's own attributes (applying through the shared inode would mutate the
* basis). Likewise `xattrs`/`fake_super` are applied only on the copy
* fallback, so a fallback copy preserves the per-file attributes instead of
* silently dropping them. */
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,
const FileXattrList* xattrs, bool fake_super,
const char* temp_dir) {
if (!path || !basis_path)
return false;
char* leaf = NULL;
@@ -1079,8 +1127,9 @@ bool file_to_disk_secure_link(const char* path, const char* basis_path, const vo
free(leaf);
/* 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_with_fsync(path, data, data_size, false, false, preallocate,
metadata, preserve_executability, use_fsync, temp_dir);
return file_to_disk_secure_attrs(path, data, data_size, false, false, preallocate, metadata,
preserve_executability, false, false, use_fsync, xattrs,
fake_super, temp_dir);
}
if (scratch_dirfd >= 0)
@@ -1090,6 +1139,24 @@ bool file_to_disk_secure_link(const char* path, const char* basis_path, const vo
return true;
}
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) {
return file_to_disk_secure_link_impl(path, basis_path, data, data_size, preallocate, metadata,
preserve_executability, 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,
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);
}
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))
+17
View File
@@ -102,6 +102,14 @@ 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 char* temp_dir);
/* Receiver write-path variant that also applies per-file xattrs (-X/-A) and the
* --fake-super stat xattr fd-relative before the final rename. `update` /
* `no_replace` / `use_fsync` mirror the plain wrappers above. */
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, const char* temp_dir);
/* Atomic --link-dest install: replace `path` with a hard link to `basis_path`
(via a temp name + rename); fall back to a byte-identical local copy from
`data` when the link is impossible (EXDEV/EPERM/unsupported filesystem).
@@ -112,5 +120,14 @@ bool file_to_disk_secure_link(const char* path, const char* basis_path, const vo
unsigned long long data_size, bool preallocate,
const FileMetadata* metadata, bool preserve_executability,
bool use_fsync, const char* temp_dir);
/* Like file_to_disk_secure_link, but the byte-copy fallback also applies the
* per-file xattrs (-X/-A) and --fake-super stat xattr (fd-relative). On a
* successful hard link no attributes are applied (the shared inode already
* carries the basis's). */
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,
bool use_fsync, const FileXattrList* xattrs, bool fake_super,
const char* temp_dir);
#endif
+83 -24
View File
@@ -21,6 +21,7 @@
#include "metadata.h"
#include "protocol.h"
#include "utils.h"
#include "xattr.h"
#define MAX_SERVER_DELETE_COUNT 100000U
#define MAX_FILE_DATA_SIZE MAX_RECEIVE_WHOLE_FILE_SIZE
@@ -85,9 +86,10 @@ static FileSaveResult file_stage_delayed_update(const char* root_directory,
config->preallocate, metadata, preserve_executability,
config->use_fsync, NULL);
} else {
ok = file_to_disk_secure_with_fsync(staged_path, file->data->data, file->data->size, false,
sparse, config->preallocate, metadata,
preserve_executability, config->use_fsync, NULL);
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, NULL);
}
if (!ok) {
free(staged_path);
@@ -250,9 +252,11 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
free(destination_path);
return absent_result;
}
bool ok =
file_to_disk_secure_link(staged_sibling, staged_first, content, content_size, preallocate,
file->metadata, preserve_executability, use_fsync, NULL);
FileXattrList* sibling_xattrs = cfg->use_xattrs ? xattr_capture_path(staged_first) : 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);
xattr_list_free(sibling_xattrs);
free(content);
if (ok)
ok = delay_updates_record(cfg->delay_context, staged_sibling, destination_path, file->path);
@@ -280,9 +284,11 @@ static FileSaveResult file_save_hardlink_sibling(const char* root_directory, con
return absent_result;
}
const char* temp_dir = (cfg && cfg->temp_dir) ? cfg->temp_dir : NULL;
bool ok =
file_to_disk_secure_link(destination_path, first_disk, content, content_size, preallocate,
file->metadata, preserve_executability, use_fsync, temp_dir);
FileXattrList* sibling_xattrs = cfg->use_xattrs ? xattr_capture_path(first_disk) : 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);
xattr_list_free(sibling_xattrs);
free(content);
free(first_disk);
free(destination_path);
@@ -773,22 +779,18 @@ 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(disk_path, file->basis_link, file->data->data, file->data->size,
config->preallocate, metadata, preserve_executability,
config->use_fsync, confined_temp);
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);
} else {
ok = config && config->ignore_existing
? file_to_disk_secure_no_replace(disk_path, file->data->data, file->data->size, sparse,
config && config->preallocate, metadata,
preserve_executability, confined_temp)
: config && config->update
? file_to_disk_secure_update(disk_path, file->data->data, file->data->size, inplace,
sparse, config && config->preallocate, metadata,
preserve_executability, confined_temp)
: file_to_disk_secure_with_fsync(disk_path, file->data->data, file->data->size,
inplace, sparse, config && config->preallocate,
metadata, preserve_executability,
config && config->use_fsync, confined_temp);
/* 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->ignore_existing,
config && config->use_fsync, file->xattrs,
config ? config->fake_super : false, confined_temp);
}
free(confined_temp);
confined_temp = NULL;
@@ -822,6 +824,21 @@ fail:
return FILE_SAVE_ERROR;
}
/* Receive a file's xattr block (when the config enables xattr transport) and
* attach it to `file`. Returns false on a malformed/oversized frame. */
static bool receive_file_xattrs(File* file, int fd, const Config* config) {
if (!config->use_xattrs)
return true;
int xok = 0;
FileXattrList* list = xattr_receive(fd, &xok);
if (!xok) {
xattr_list_free(list);
return false;
}
file->xattrs = list;
return true;
}
static File* receive_delta_file(int fd, const Config* config, const char* check_path,
void* old_data, unsigned long long old_size, bool* failed) {
if (!old_data) {
@@ -937,6 +954,14 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
return NULL;
}
}
if (!receive_file_xattrs(file, fd, config)) {
file_destroy(file);
free(new_data);
free(old_data);
delta_signature_destroy(sig);
*failed = true;
return NULL;
}
Data* replacement = data_create(new_data, (size_t)new_size);
if (replacement == NULL) {
@@ -974,6 +999,11 @@ static File* receive_delta_file(int fd, const Config* config, const char* check_
return NULL;
}
}
if (!receive_file_xattrs(file, fd, config)) {
file_destroy(file);
*failed = true;
return NULL;
}
Data* file_data = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE);
if (file_data == NULL) {
@@ -1479,6 +1509,10 @@ static File* receive_full_file(int fd, const Config* config, const char* path) {
return NULL;
}
}
if (!receive_file_xattrs(file, fd, config)) {
file_destroy(file);
return NULL;
}
Data* file_data = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE);
if (file_data == NULL) {
file_destroy(file);
@@ -1851,6 +1885,7 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
return NULL;
}
FileMetadata* meta = NULL;
FileXattrList* append_xattrs = NULL;
if (config->use_metadata) {
int meta_ok = 1;
meta = metadata_receive(fd, &meta_ok);
@@ -1862,8 +1897,21 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
return NULL;
}
}
if (config->use_xattrs) {
int xok = 0;
append_xattrs = xattr_receive(fd, &xok);
if (!xok) {
xattr_list_free(append_xattrs);
close(old_fd);
free(full_path);
free(check_path);
free(old_data);
return NULL;
}
}
Data* tail = receive_data_limited(fd, MAX_RECEIVE_WHOLE_FILE_SIZE);
if (tail == NULL) {
xattr_list_free(append_xattrs);
close(old_fd);
free(full_path);
free(check_path);
@@ -1877,6 +1925,7 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
Data* uncompressed = data_decompress_limited(tail, MAX_RECEIVE_WHOLE_FILE_SIZE);
data_destroy(tail);
if (uncompressed == NULL) {
xattr_list_free(append_xattrs);
close(old_fd);
free(full_path);
free(check_path);
@@ -1885,6 +1934,7 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
}
if (uncompressed->size > MAX_FILE_DATA_SIZE) {
data_destroy(uncompressed);
xattr_list_free(append_xattrs);
close(old_fd);
free(full_path);
free(check_path);
@@ -1900,6 +1950,7 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
tail->size != (size_t)expected_tail) {
send_status(fd, STATUS_ERROR);
data_destroy(tail);
xattr_list_free(append_xattrs);
close(old_fd);
free(full_path);
free(check_path);
@@ -1910,6 +1961,7 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
void* full = protocol_alloc(full_size ? full_size : 1);
if (!full) {
data_destroy(tail);
xattr_list_free(append_xattrs);
close(old_fd);
free(full_path);
free(check_path);
@@ -1927,12 +1979,15 @@ File* receive_incremental_check(int fd, const Config* config, bool* skipped) {
File* file = file_create(check_path);
if (!file) {
free(full);
xattr_list_free(append_xattrs);
close(old_fd);
free(full_path);
free(check_path);
return NULL;
}
file->metadata = meta;
file->xattrs = append_xattrs;
append_xattrs = NULL;
file->data = data_create(full, full_size);
if (!file->data) { /* data_create already freed full on failure */
file_destroy(file);
@@ -2041,6 +2096,10 @@ File* file_receive(const Config* config, int file_descriptor) {
return NULL;
}
}
if (!receive_file_xattrs(file, file_descriptor, config)) {
file_destroy(file);
return NULL;
}
Data* file_data = receive_data_limited(file_descriptor, MAX_RECEIVE_WHOLE_FILE_SIZE);
if (file_data == NULL) {
file_destroy(file);
+12 -5
View File
@@ -16,6 +16,7 @@
#include "log.h"
#include "metadata.h"
#include "protocol.h"
#include "xattr.h"
/* Transmit a device/special node (--devices / --specials) as a STATUS_SPECIAL
* frame: the destination path, the metadata frame (whose mode's S_IFMT bits
@@ -39,13 +40,13 @@ bool file_send_special(File* file, int file_descriptor, bool use_metadata) {
bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata,
int compression_level, bool send_path) {
return file_send_single_calls_with_skip(file, file_descriptor, use_metadata, compression_level,
send_path, NULL, -1, 0);
send_path, NULL, -1, 0, false);
}
bool file_send_single_calls_with_skip(File* file, int file_descriptor, bool use_metadata,
int compression_level, bool send_path,
char* const* skip_suffixes, int skip_count,
int compression_threads) {
int compression_threads, bool send_xattrs) {
if (!file || !file->path || !file->data || (file->data->size != 0 && !file->data->data))
return false;
const Data* data_to_send = file->data;
@@ -68,6 +69,10 @@ bool file_send_single_calls_with_skip(File* file, int file_descriptor, bool use_
data_destroy(compressed_data);
return false;
}
if (send_xattrs && !xattr_send(file_descriptor, file ? file->xattrs : NULL)) {
data_destroy(compressed_data);
return false;
}
if (!send_data(file_descriptor, data_to_send)) {
data_destroy(compressed_data);
return false;
@@ -79,23 +84,25 @@ bool file_send_single_calls_with_skip(File* file, int file_descriptor, bool use_
bool file_send_sendfile(File* file, int file_descriptor, bool use_metadata, int compression_level,
bool send_path) {
return file_send_sendfile_with_skip(file, file_descriptor, use_metadata, compression_level,
send_path, NULL, -1, 0);
send_path, NULL, -1, 0, false);
}
bool file_send_sendfile_with_skip(File* file, int file_descriptor, bool use_metadata,
int compression_level, bool send_path, char* const* skip_suffixes,
int skip_count, int compression_threads) {
int skip_count, int compression_threads, bool send_xattrs) {
if (!file || !file->path || !file->data)
return false;
if (compression_level > 0)
return file_send_single_calls_with_skip(file, file_descriptor, use_metadata, compression_level,
send_path, skip_suffixes, skip_count,
compression_threads);
compression_threads, send_xattrs);
if (send_path && !send_str(file_descriptor, file_wire_path(file)))
return false;
if (use_metadata && !metadata_send(file_descriptor, file->metadata))
return false;
if (send_xattrs && !xattr_send(file_descriptor, file ? file->xattrs : NULL))
return false;
int fd = file_open_for_read(file->path);
if (fd == -1) {
+2 -2
View File
@@ -12,11 +12,11 @@ bool file_send_single_calls(File* file, int file_descriptor, bool use_metadata,
bool file_send_single_calls_with_skip(File* file, int file_descriptor, bool use_metadata,
int compression_level, bool send_path,
char* const* skip_suffixes, int skip_count,
int compression_threads);
int compression_threads, bool send_xattrs);
bool file_send_sendfile(File* file, int file_descriptor, bool use_metadata, int compression_level,
bool send_path);
bool file_send_sendfile_with_skip(File* file, int file_descriptor, bool use_metadata,
int compression_level, bool send_path, char* const* skip_suffixes,
int skip_count, int compression_threads);
int skip_count, int compression_threads, bool send_xattrs);
#endif
+6
View File
@@ -2,6 +2,7 @@
#define FILE_TYPES_H
#include "data.h"
#include "xattr.h"
#include <stdbool.h>
#include <sys/stat.h>
@@ -72,6 +73,11 @@ typedef struct {
bool is_special;
int32_t rdev_major;
int32_t rdev_minor;
/* Phase-4 xattrs (-X/--xattrs, -A/--acls). Sender: captured from the source
* file when use_xattrs is set; transmitted in the per-file metadata frame.
* Receiver: parsed off the wire, attached here, and applied fd-relative on
* the written file. NULL/0 == the file carries no xattrs. */
FileXattrList* xattrs;
} File;
/* The path that should be sent on the wire and used for the receiver-side
+331
View File
@@ -0,0 +1,331 @@
#define _GNU_SOURCE
#include "xattr.h"
#include "log.h"
#include "protocol.h"
#include "utils.h"
#include "file_types.h"
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/xattr.h>
/* ---- lifecycle ---- */
FileXattrList* xattr_list_new(void) {
FileXattrList* list = protocol_alloc(sizeof(FileXattrList));
if (!list)
return NULL;
list->items = NULL;
list->count = 0;
return list;
}
void xattr_list_free(FileXattrList* list) {
if (!list)
return;
for (int i = 0; i < list->count; i++) {
free(list->items[i].name);
free(list->items[i].value);
}
free(list->items);
free(list);
}
bool xattr_list_append(FileXattrList* list, const char* name, const void* value, size_t value_len) {
if (!list || !name || (!value && value_len != 0))
return false;
if (list->count >= XATTR_MAX_COUNT)
return false;
FileXattr* grown = realloc(list->items, ((size_t)list->count + 1) * sizeof(FileXattr));
if (!grown)
return false;
list->items = grown;
size_t name_len = strlen(name);
char* name_copy = malloc(name_len + 1);
if (!name_copy) {
return false;
}
unsigned char* value_copy = NULL;
if (value_len > 0) {
value_copy = malloc(value_len);
if (!value_copy) {
free(name_copy);
return false;
}
memcpy(value_copy, value, value_len);
}
memcpy(name_copy, name, name_len);
name_copy[name_len] = '\0';
list->items[list->count].name = name_copy;
list->items[list->count].value = value_copy;
list->items[list->count].value_len = value_len;
list->count++;
return true;
}
/* ---- namespace / length validation ---- */
/* A Linux xattr name is "namespace.name" with an optional leading "trusted.",
* "system.", "security.", "user.", or "trusted." prefix. We only ever touch
* the unprivileged "user.*" namespace and the two POSIX ACL xattrs carried in
* the "system." namespace. Everything else -- especially "security.*" (ACLs,
* capabilities, SELinux labels) and "trusted.*" -- is refused so a client can
* never compel the receiver to apply a privileged attribute it would not
* otherwise be able to set (and which would be a local privilege escalation if
* it could). */
bool xattr_name_appliable(const char* name) {
if (!name || name[0] == '\0')
return false;
size_t len = strlen(name);
if (len > XATTR_NAME_MAX)
return false;
/* The reserved --fake-super key is exclusively the RECEIVER's: it records the
* source stat for a later privileged restore. A plain -X run must never
* forward a source file that already carries this key (spoofable) onto the
* destination, so it is excluded from capture AND from application. Only
* fake_super_store_fd() writes it. */
if (strcmp(name, FAKESUPER_XATTR) == 0)
return false;
if (strncmp(name, "user.", 5) == 0)
return name[5] != '\0';
if (strcmp(name, "system.posix_acl_access") == 0)
return true;
if (strcmp(name, "system.posix_acl_default") == 0)
return true;
return false;
}
/* ---- SENDER: capture ---- */
FileXattrList* xattr_capture_path(const char* path) {
if (!path)
return NULL;
ssize_t list_size = listxattr(path, NULL, 0);
if (list_size <= 0)
return NULL; /* no xattrs, ENOTSUP, or error: nothing appliable */
char* names = malloc((size_t)list_size);
if (!names)
return NULL;
ssize_t got = listxattr(path, names, (size_t)list_size);
if (got < 0) {
free(names);
return NULL;
}
FileXattrList* list = xattr_list_new();
if (!list) {
free(names);
return NULL;
}
size_t budget = 0;
ssize_t offset = 0;
while (offset < got) {
const char* name = names + offset;
size_t name_len = strlen(name);
if (name_len == 0)
break; /* trailing double NUL not expected; stop */
offset += (ssize_t)name_len + 1;
if (!xattr_name_appliable(name))
continue;
ssize_t value_size = getxattr(path, name, NULL, 0);
if (value_size < 0)
continue;
if (value_size > XATTR_VALUE_MAX)
continue; /* oversize value is refused up front (bounded capture) */
if (name_len + (size_t)value_size > XATTR_TOTAL_MAX - budget)
continue; /* would exceed the per-file budget: skip, keep the rest */
unsigned char* buffer = malloc(value_size > 0 ? (size_t)value_size : 1);
if (!buffer) {
xattr_list_free(list);
free(names);
return NULL;
}
ssize_t read_len = getxattr(path, name, buffer, (size_t)value_size);
if (read_len < 0 || read_len != value_size) {
free(buffer);
continue;
}
if (!xattr_list_append(list, name, buffer, (size_t)value_size)) {
free(buffer);
xattr_list_free(list);
free(names);
return NULL;
}
free(buffer);
budget += name_len + (size_t)value_size;
}
free(names);
if (list->count == 0) {
xattr_list_free(list);
return NULL;
}
return list;
}
/* ---- WIRE ---- */
bool xattr_send(int fd, const FileXattrList* list) {
int count = list ? list->count : 0;
if (!send_int(fd, count))
return false;
for (int i = 0; i < count; i++) {
const FileXattr* xa = &list->items[i];
size_t name_len = strlen(xa->name);
if (name_len > INT32_MAX)
return false;
int32_t name_len32 = (int32_t)name_len;
if (xa->value_len > INT32_MAX)
return false;
int32_t value_len32 = (int32_t)xa->value_len;
if (!send_n_data(fd, &name_len32, sizeof(name_len32)) || !send_n_data(fd, xa->name, name_len) ||
!send_n_data(fd, &value_len32, sizeof(value_len32)) ||
(value_len32 > 0 && !send_n_data(fd, xa->value, (size_t)value_len32)))
return false;
}
return true;
}
FileXattrList* xattr_receive(int fd, int* ok) {
if (ok)
*ok = 0;
int count;
if (!receive_int(fd, &count))
return NULL;
if (count < 0 || count > XATTR_MAX_COUNT) {
log_message(LOG_LEVEL_ERROR, "rejected xattr block: invalid attribute count %d", count);
return NULL;
}
FileXattrList* list = xattr_list_new();
if (!list)
return NULL;
size_t budget = 0;
for (int i = 0; i < count; i++) {
int32_t name_len32;
if (!receive_n_data(fd, &name_len32, sizeof(name_len32))) {
xattr_list_free(list);
return NULL;
}
if (name_len32 <= 0 || name_len32 > XATTR_NAME_MAX) {
log_message(LOG_LEVEL_ERROR, "rejected xattr block: invalid name length %d", name_len32);
xattr_list_free(list);
return NULL;
}
char* name = protocol_alloc((size_t)name_len32 + 1);
if (!name) {
xattr_list_free(list);
return NULL;
}
if (!receive_n_data(fd, name, (size_t)name_len32)) {
free(name);
xattr_list_free(list);
return NULL;
}
name[name_len32] = '\0';
if (memchr(name, '\0', (size_t)name_len32) != NULL) {
/* embedded NUL in the name: malformed, reject */
free(name);
xattr_list_free(list);
return NULL;
}
if (!xattr_name_appliable(name)) {
log_message(LOG_LEVEL_ERROR, "rejected xattr block: disallowed namespace for '%s'", name);
free(name);
xattr_list_free(list);
return NULL;
}
int32_t value_len32;
if (!receive_n_data(fd, &value_len32, sizeof(value_len32))) {
free(name);
xattr_list_free(list);
return NULL;
}
if (value_len32 < 0 || value_len32 > XATTR_VALUE_MAX) {
log_message(LOG_LEVEL_ERROR, "rejected xattr block: invalid value length %d for '%s'",
value_len32, name);
free(name);
xattr_list_free(list);
return NULL;
}
if ((size_t)name_len32 + (size_t)value_len32 > XATTR_TOTAL_MAX - budget) {
log_message(LOG_LEVEL_ERROR, "rejected xattr block: total size budget exceeded for '%s'",
name);
free(name);
xattr_list_free(list);
return NULL;
}
unsigned char* value = NULL;
if (value_len32 > 0) {
value = protocol_alloc((size_t)value_len32);
if (!value) {
free(name);
xattr_list_free(list);
return NULL;
}
if (!receive_n_data(fd, value, (size_t)value_len32)) {
free(value);
free(name);
xattr_list_free(list);
return NULL;
}
}
if (!xattr_list_append(list, name, value, (size_t)value_len32)) {
free(value);
free(name);
xattr_list_free(list);
return NULL;
}
free(value);
free(name);
budget += (size_t)name_len32 + (size_t)value_len32;
}
if (ok)
*ok = 1;
return list;
}
/* ---- RECEIVER: apply (fd-relative, best-effort) ---- */
bool xattr_apply_fd(int fd, const FileXattrList* list) {
if (fd < 0 || !list)
return false;
bool warned = false;
int first_errno = 0;
for (int i = 0; i < list->count; i++) {
const FileXattr* xa = &list->items[i];
/* Defense in depth: even a hand-crafted list can never apply the reserved
--fake-super key (only fake_super_store_fd may write it). */
if (strcmp(xa->name, FAKESUPER_XATTR) == 0)
continue;
if (fsetxattr(fd, xa->name, xa->value, xa->value_len, 0) != 0) {
if (!warned) {
warned = true;
first_errno = errno;
}
}
}
/* Collapse potentially many per-attribute failures into one per-file warning
so a run with many unsettable attributes does not spam the log. */
if (warned)
log_message(LOG_LEVEL_WARNING, "could not set one or more xattrs on the destination file: %s",
strerror(first_errno));
return true;
}
/* ---- --fake-super: park ownership/mode/mtime in a reserved xattr ---- */
void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int64_t mtime_sec,
int64_t mtime_nsec) {
if (fd < 0)
return;
char record[128];
int len =
snprintf(record, sizeof(record), "%lu:%lu:%03o:%lld:%ld", (unsigned long)uid,
(unsigned long)gid, (unsigned)mode & 0777U, (long long)mtime_sec, (long)mtime_nsec);
if (len <= 0 || (size_t)len >= sizeof(record))
return;
if (fsetxattr(fd, FAKESUPER_XATTR, record, (size_t)len, 0) != 0) {
log_message(LOG_LEVEL_WARNING, "--fake-super: could not store %s on destination file: %s",
FAKESUPER_XATTR, strerror(errno));
}
}
+89
View File
@@ -0,0 +1,89 @@
#ifndef XATTR_H
#define XATTR_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/*
* Portable extended-attribute (xattr) and POSIX-ACL preservation (Phase 4,
* protocol 2.13.0). --xattrs/-X and --acls/-A are implemented on top of the
* xattr machinery: the SENDER captures a bounded, namespace-whitelisted set of
* `name = value` pairs per file, transmits them in a per-file wire block, and
* the RECEIVER re-applies them fd-relative on the just-written file. Linux
* xattr syscalls are used; libacl is NOT required (ACLs travel as the
* system.posix_acl_access / system.posix_acl_default xattrs).
*
* Security model:
* * A client can never force a `security.*` / privileged xattr onto the
* destination: both capture (sender) and apply (receiver) are restricted to
* the unprivileged `user.*` namespace and the two POSIX ACL xattrs. The
* receiver independently re-validates every incoming name against this
* whitelist, so a malicious sender's `security.capability` payload is
* rejected, not applied.
* * Payloads are bounded (per-name length, per-value length, per-file count
* and total bytes) on BOTH ends to prevent OOM/memory abuse; an oversized
* or malformed frame is a clean protocol rejection, never an allocation
* blowup.
* * Application is confined to the exact destination file descriptor
* (fsetxattr on the just-written fd), never a caller-controlled path.
*/
/* Reserved key used by --fake-super to park the source's privileged ownership
* / mode / mtime on the destination file as an unprivileged user.* xattr, so a
* later privileged restore could re-apply them. Exact documented format:
* uid:gid:mode:mtime_sec:mtime_nsec (decimal, decimal, octal, dec, dec)
* e.g. "1000:1000:644:1765238400:0". */
#define FAKESUPER_XATTR "user.fastsync.stat"
/* --- bounds --- */
#define XATTR_NAME_MAX 255 /* xattr names are limited to 255 bytes */
#define XATTR_VALUE_MAX (1024 * 1024) /* per-value cap (1 MiB) */
#define XATTR_TOTAL_MAX (4 * 1024 * 1024) /* per-file total name+value bytes */
#define XATTR_MAX_COUNT 256
typedef struct {
char* name; /* owned, NUL-terminated */
unsigned char* value; /* owned, may hold embedded NULs */
size_t value_len;
} FileXattr;
typedef struct {
FileXattr* items;
int count;
} FileXattrList;
FileXattrList* xattr_list_new(void);
void xattr_list_free(FileXattrList* list);
/* Append one entry (deep copy). Returns false on allocation failure. */
bool xattr_list_append(FileXattrList* list, const char* name, const void* value, size_t value_len);
/* True when `name` is a well-formed xattr name AND belongs to a namespace this
* build is authorized to apply (user.* or the two POSIX ACL xattrs). Used for
* both capture and receiver-side validation. */
bool xattr_name_appliable(const char* name);
/* Sender: read the whitelisted xattrs of `path` into a new list. Returns NULL
* when the path has no appliable xattrs (or the filesystem has no xattr
* support); an empty-but-valid list is never returned distinct from NULL. */
FileXattrList* xattr_capture_path(const char* path);
/* Wire: bounded serialization. xattr_send returns false on write failure; an
* empty/NULL list transmits a zero-count block. xattr_receive returns NULL and
* sets *ok = 0 on any malformed / oversized / non-whitelisted entry. */
bool xattr_send(int fd, const FileXattrList* list);
FileXattrList* xattr_receive(int fd, int* ok);
/* Receiver: apply every entry fd-relative (fsetxattr) to the just-written file
* descriptor. A per-attribute failure (e.g. ACL set refused for non-root on a
* file the process does not own) is logged and skipped, never fatal. Returns
* true when apply was attempted (allowing callers to treat it as best-effort). */
bool xattr_apply_fd(int fd, const FileXattrList* list);
/* --fake-super: write the source uid/gid/mode/mtime record into the reserved
* FAKESUPER_XATTR on `fd`. Best-effort (logged, never fatal). Only meaningful
* when metadata was transmitted so the values exist. */
void fake_super_store_fd(int fd, uint32_t uid, uint32_t gid, uint32_t mode, int64_t mtime_sec,
int64_t mtime_nsec);
#endif
+172
View File
@@ -4432,3 +4432,175 @@ class TestSymlinkTrust:
prefixed = os.path.join(received, "prefixed")
assert os.path.islink(prefixed)
assert os.readlink(prefixed) == "#SYMLINK/realfile.txt"
def _xattr_supported(path):
"""True when the filesystem hosting `path` supports user xattrs."""
try:
os.setxattr(path, "user.fastsync-probe", b"p")
os.removexattr(path, "user.fastsync-probe")
return True
except (OSError, AttributeError):
return False
class TestExtendedAttributes:
"""-X/--xattrs, -A/--acls, --fake-super: portable extended metadata.
Runs unprivileged (CI is non-root). Everything is best-effort and guarded:
a filesystem without xattr support, or an ACL toolchain/POSIX-ACL
filesystem feature that is missing, is skipped rather than failed. The
security boundary (only user.* and the system.posix_acl_* namespaces are
ever applied) is asserted alongside the happy path."""
def _source_and_dest(self, name):
source = os.path.join(TEST_DATA_DIR, name + "_src")
dest = os.path.join(TEST_DATA_DIR, name + "_dst")
clean_dir(source)
clean_dir(dest)
return source, dest
@pytest.mark.ci
def test_xattrs_preserves_user_namespace(self, shared_server):
source, dest = self._source_and_dest("xattr")
f = os.path.join(source, "data.txt")
with open(f, "wb") as fh:
fh.write(b"xattr payload\n")
if not _xattr_supported(f):
pytest.skip("filesystem does not support user xattrs")
os.setxattr(f, "user.foo", b"preserved-value")
result, _ = run_client(source, dest, flags=["-X"], port=shared_server.port)
assert result.returncode == 0, \
f"-X sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
assert os.getxattr(os.path.join(received, "data.txt"), "user.foo") == b"preserved-value"
def test_without_xattrs_does_not_carry(self, shared_server):
source, dest = self._source_and_dest("xattr_ctrl")
f = os.path.join(source, "data.txt")
with open(f, "wb") as fh:
fh.write(b"plain\n")
if not _xattr_supported(f):
pytest.skip("filesystem does not support user xattrs")
os.setxattr(f, "user.foo", b"must-not-travel")
result, _ = run_client(source, dest, port=shared_server.port)
assert result.returncode == 0, \
f"control sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
with pytest.raises(OSError):
os.getxattr(os.path.join(received, "data.txt"), "user.foo")
def test_reserved_fake_super_key_not_forwarded(self, shared_server):
"""A source file that already carries the reserved user.fastsync.stat
record must NOT have it planted on the receiver during a plain -X run
(it is receiver-only, so it cannot be spoofed for a later privileged
restore)."""
source, dest = self._source_and_dest("xattr_reserved")
f = os.path.join(source, "data.txt")
with open(f, "wb") as fh:
fh.write(b"reserved\n")
if not _xattr_supported(f):
pytest.skip("filesystem does not support user xattrs")
os.setxattr(f, "user.fastsync.stat", b"0:0:644:0:0")
# A normal user.* attr still travels alongside.
os.setxattr(f, "user.keep", b"yes")
result, _ = run_client(source, dest, flags=["-X"], port=shared_server.port)
assert result.returncode == 0, \
f"-X reserved-key sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
assert os.getxattr(os.path.join(received, "data.txt"), "user.keep") == b"yes"
with pytest.raises(OSError):
os.getxattr(os.path.join(received, "data.txt"), "user.fastsync.stat")
@pytest.mark.ci
def test_xattrs_multithreaded(self, shared_server):
source, dest = self._source_and_dest("xattr_mt")
f = os.path.join(source, "data.txt")
with open(f, "wb") as fh:
fh.write(b"mt xattr\n")
if not _xattr_supported(f):
pytest.skip("filesystem does not support user xattrs")
os.setxattr(f, "user.k", b"v")
result, _ = run_client(source, dest, flags=["-X", "-m"], port=shared_server.port)
assert result.returncode == 0, \
f"-X -m sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
assert os.getxattr(os.path.join(received, "data.txt"), "user.k") == b"v"
@pytest.mark.ci
def test_acls_via_posix_acl_xattr(self, shared_server):
source, dest = self._source_and_dest("acl")
f = os.path.join(source, "data.txt")
with open(f, "wb") as fh:
fh.write(b"acl payload\n")
if not _xattr_supported(f):
pytest.skip("filesystem does not support xattrs")
acl_blob = None
if shutil.which("setfacl") is not None:
acl = subprocess.run(["setfacl", "-m", "o::r", f], capture_output=True, text=True)
if acl.returncode == 0:
try:
acl_blob = os.getxattr(f, "system.posix_acl_access")
except OSError:
acl_blob = None
if acl_blob is None:
# No setfacl (common in the minimal CI image): synthesize a valid
# non-trivial POSIX ACL ("u:current-uid:r") xattr blob directly.
import struct
try:
uid_for_acl = os.geteuid() if os.geteuid() != 0 else 65534
struct_entry = struct.pack("<HHI", 0x01, 0x4, 0xFFFFFFFF) # USER_OBJ r
struct_entry += struct.pack("<HHI", 0x02, 0x4, uid_for_acl) # USER r
struct_entry += struct.pack("<HHI", 0x04, 0x4, 0xFFFFFFFF) # GROUP_OBJ r
struct_entry += struct.pack("<HHI", 0x10, 0x4, 0xFFFFFFFF) # MASK r
struct_entry += struct.pack("<HHI", 0x20, 0x0, 0xFFFFFFFF) # OTHER ---
blob = struct.pack("<I", 2) + struct_entry
os.setxattr(f, "system.posix_acl_access", blob)
acl_blob = os.getxattr(f, "system.posix_acl_access")
except (OSError, struct.error) as e:
pytest.skip(f"cannot set a POSIX ACL unprivileged: {e}")
result, _ = run_client(source, dest, flags=["-A"], port=shared_server.port)
assert result.returncode == 0, \
f"-A sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
assert os.getxattr(os.path.join(received, "data.txt"),
"system.posix_acl_access") == acl_blob
@pytest.mark.ci
def test_acls_imply_xattr_transport(self, shared_server):
"""-A and -X enable the shared xattr transport; both attributes travel
together, and a security.* attribute a malicious peer would send is
never applied (receiver whitelist)."""
source, dest = self._source_and_dest("acl_xattr")
f = os.path.join(source, "data.txt")
with open(f, "wb") as fh:
fh.write(b"combined\n")
if not _xattr_supported(f):
pytest.skip("filesystem does not support xattrs")
os.setxattr(f, "user.for-acl-flag", b"yes")
result, _ = run_client(source, dest, flags=["-A", "-X"], port=shared_server.port)
assert result.returncode == 0, \
f"-A -X sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
assert os.getxattr(os.path.join(received, "data.txt"), "user.for-acl-flag") == b"yes"
@pytest.mark.ci
def test_fake_super_stores_source_stat(self, shared_server):
source, dest = self._source_and_dest("fakesuper")
f = os.path.join(source, "data.txt")
with open(f, "wb") as fh:
fh.write(b"fake-super\n")
if not _xattr_supported(f):
pytest.skip("filesystem does not support xattrs")
uid = os.stat(f).st_uid
result, _ = run_client(source, dest, flags=["--fake-super"], port=shared_server.port)
assert result.returncode == 0, \
f"--fake-super sync failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
record = os.getxattr(os.path.join(received, "data.txt"), "user.fastsync.stat").decode()
fields = record.split(":")
assert len(fields) == 5
assert fields[0] == str(uid), f"reserved uid field {fields[0]} != source uid {uid}"
+2
View File
@@ -27,6 +27,7 @@
#include "test_transport_ssh.h"
#include "test_transport_tls.h"
#include "test_utils.h"
#include "test_xattr.h"
#include <stdio.h>
#include <signal.h>
@@ -67,6 +68,7 @@ int main() {
RUN_TEST(test_client_cli);
RUN_TEST(test_server);
RUN_TEST(test_fuzz_smoke);
RUN_TEST(test_xattr);
printf("\n\033[1;36m=== TEST SUMMARY ===\033[0m\n");
printf("Total Tests Run: %d\n", tests_run);
+58
View File
@@ -210,6 +210,60 @@ static void test_parse_args_version() {
config_delete(cfg);
}
/* --xattrs/-X and --acls/-A preserve per-file xattrs and both imply metadata
* transmission (the xattr block rides the metadata/per-file frame); each is
* individually negatable and the derived use_xattrs follows the flags. */
static void test_parse_args_xattrs_acls() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "-X", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->preserve_xattrs);
EXPECT_FALSE(cfg->preserve_acls);
EXPECT_TRUE(cfg->use_xattrs);
EXPECT_TRUE(cfg->use_metadata);
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* argv_long[] = {"fastsync", "--acls", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv_long, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->preserve_acls);
EXPECT_TRUE(cfg->use_xattrs);
EXPECT_TRUE(cfg->use_metadata);
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* argv_neg[] = {"fastsync", "-X", "-A", "--no-xattrs", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 6, argv_neg, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->preserve_xattrs);
EXPECT_TRUE(cfg->preserve_acls);
EXPECT_TRUE(cfg->use_xattrs);
config_delete(cfg);
}
/* --fake-super is a receiver-side preference that parks the source
* uid/gid/mode/mtime in a reserved xattr; it implies metadata transmission. */
static void test_parse_args_fake_super() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--fake-super", "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->fake_super);
EXPECT_TRUE(cfg->use_metadata);
config_delete(cfg);
cfg = config_create();
positional_count = 0;
char* argv_neg[] = {"fastsync", "--fake-super", "--no-fake-super", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv_neg, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->fake_super);
config_delete(cfg);
}
/* Test parse_args with valid port */
static void test_parse_args_valid_port() {
Config* cfg = config_create();
@@ -772,6 +826,8 @@ static void test_parse_args_rejects_unimplemented_options() {
"--acls",
"-X",
"--xattrs",
"-D",
"--devices",
"--delete-excluded",
"--max-delete",
"--prune-empty-dirs",
@@ -2526,6 +2582,8 @@ void test_client_cli() {
test_parse_args_table_equals_size_options();
test_parse_args_table_equals_string_and_int_options();
test_parse_args_missing_argument_diagnostic();
test_parse_args_xattrs_acls();
test_parse_args_fake_super();
test_parse_args_partial_progress();
test_parse_args_itemize_changes();
test_parse_args_list_only();
+44
View File
@@ -1183,6 +1183,49 @@ static void test_config_devices_wire_roundtrip() {
}
}
/* Phase-4: preserve_xattrs/--acls (in file options) and --fake-super (trailing)
* cross the config wire; the receiver recomputes the derived use_xattrs. */
static void test_config_phase4_xattr_wire_roundtrip() {
if (is_running_under_valgrind())
return;
Config* send_cfg = config_create();
EXPECT_NOT_NULL(send_cfg);
send_cfg->send_directory = str_dup("/send/src");
send_cfg->receive_root_directory = str_dup("/send/dst");
send_cfg->preserve_xattrs = true;
send_cfg->preserve_acls = true;
send_cfg->fake_super = true;
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
io_set_fds(p[0], p[0]);
Config* recv = config_receive(p[0]);
bool ok = recv != NULL;
if (ok) {
ok = recv->preserve_xattrs && recv->preserve_acls && recv->fake_super && recv->use_xattrs;
}
config_delete(recv);
close(p[0]);
close(p[1]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
bool sent = config_send(p[1], send_cfg);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(send_cfg);
EXPECT_TRUE(sent);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
void test_config() {
test_config_lifecycle();
test_config_ssh_dest();
@@ -1213,6 +1256,7 @@ void test_config() {
test_config_metadata_times_wire_roundtrip();
test_config_devices_wire_roundtrip();
test_config_preallocate_wire_roundtrip();
test_config_phase4_xattr_wire_roundtrip();
}
test_config_delete_timing_early_helper();
test_config_is_remote_dest();
+234
View File
@@ -0,0 +1,234 @@
#include "test_xattr.h"
#include "xattr.h"
#include "file.h"
#include "protocol.h"
#include "test_utils.h"
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <sys/xattr.h>
#include <unistd.h>
static void run_recv_helper(int fd) {
int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok);
if (!ok)
_exit(1);
if (!list) {
/* NULL list only on error, already handled above. */
_exit(1);
}
if (list->count != 2)
_exit(1);
if (strcmp(list->items[0].name, "user.foo") != 0 || list->items[0].value_len != 3 ||
memcmp(list->items[0].value, "bar", 3) != 0)
_exit(1);
if (strcmp(list->items[1].name, "user.empty") != 0 || list->items[1].value_len != 0)
_exit(1);
xattr_list_free(list);
_exit(0);
}
static void test_xattr_wire_roundtrip() {
/* Round-trip a user.* list incl. an empty value. */
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
io_set_fds(p[0], p[0]);
run_recv_helper(p[0]);
}
close(p[0]);
io_set_fds(p[1], p[1]);
FileXattrList* list = xattr_list_new();
EXPECT_NOT_NULL(list);
EXPECT_TRUE(xattr_list_append(list, "user.foo", "bar", 3));
EXPECT_TRUE(xattr_list_append(list, "user.empty", NULL, 0));
EXPECT_TRUE(xattr_send(p[1], list));
xattr_list_free(list);
int status;
waitpid(pid, &status, 0);
close(p[1]);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
static void run_recv_must_fail(int fd) {
int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok);
/* A NULL list with ok==0 is the expected rejection. */
if (ok == 0 && list == NULL)
_exit(0);
xattr_list_free(list);
_exit(1);
}
/* A receiver must reject a security.* (privileged-namespace) attribute, never
* apply it: the send side can be malicious, so only the receiver whitelist
* matters. */
static void test_xattr_reject_privileged_namespace() {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
io_set_fds(p[0], p[0]);
run_recv_must_fail(p[0]);
}
close(p[0]);
io_set_fds(p[1], p[1]);
FileXattrList* list = xattr_list_new();
EXPECT_NOT_NULL(list);
/* security.capability must be rejected by the receiver. */
EXPECT_TRUE(xattr_list_append(list, "security.capability", "\x01\x00", 2));
xattr_send(p[1], list); /* receiver rejects at the name check and exits */
xattr_list_free(list);
int status;
waitpid(pid, &status, 0);
close(p[1]);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
/* An oversized value (beyond XATTR_VALUE_MAX) must be rejected on receive. */
static void test_xattr_reject_oversized_value() {
int p[2];
EXPECT_EQ_INT(pipe(p), 0);
io_set_fds(p[0], p[1]);
io_set_bwlimit(0);
pid_t pid = fork();
if (pid == 0) {
close(p[1]);
io_set_fds(p[0], p[0]);
run_recv_must_fail(p[0]);
}
close(p[0]);
io_set_fds(p[1], p[1]);
FileXattrList* list = xattr_list_new();
EXPECT_NOT_NULL(list);
size_t huge = (size_t)XATTR_VALUE_MAX + 1;
unsigned char* blob = calloc(1, huge);
EXPECT_NOT_NULL(blob);
EXPECT_TRUE(xattr_list_append(list, "user.huge", blob, huge));
xattr_send(p[1], list); /* send is best-effort; the receiver rejects and exits */
free(blob);
xattr_list_free(list);
int status;
waitpid(pid, &status, 0);
close(p[1]);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
/* The list append enforces the count bound (defense in depth). */
static void test_xattr_count_bound() {
FileXattrList* list = xattr_list_new();
EXPECT_NOT_NULL(list);
bool all_ok = true;
for (int i = 0; i < XATTR_MAX_COUNT + 1; i++) {
char name[32];
snprintf(name, sizeof(name), "user.k%d", i);
if (!xattr_list_append(list, name, "v", 1))
all_ok = false;
}
EXPECT_FALSE(all_ok);
EXPECT_EQ_INT(list->count, XATTR_MAX_COUNT);
xattr_list_free(list);
}
/* The captured list on a plain file reflects only whitelisted namespaces
* (Linux only; skipped when the filesystem has no xattr support). */
static void test_xattr_capture_and_appliable() {
EXPECT_FALSE(xattr_name_appliable(NULL));
EXPECT_FALSE(xattr_name_appliable(""));
EXPECT_FALSE(xattr_name_appliable("security.selinux"));
EXPECT_FALSE(xattr_name_appliable("trusted.blob"));
EXPECT_TRUE(xattr_name_appliable("user.foo"));
/* The reserved fake-super key is receiver-only and never forwarded/applied. */
EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat"));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_access"));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_default"));
}
/* MINOR-2: a --link-dest / -H copy fallback (linkat refused) must still apply
* the per-file xattrs and --fake-super stat. A DIRECTORY basis forces linkat
* to fail with EPERM, exercising the byte-copy fallback deterministically.
* Guarded on filesystem xattr support. */
static void test_link_copy_fallback_preserves_xattrs() {
const char* dest = "test_link_xattr_dest.txt";
const char* basis_dir = "test_link_xattr_basis_dir";
unlink(dest);
rmdir(basis_dir);
EXPECT_EQ_INT(mkdir(basis_dir, 0700), 0);
/* Probe xattr support on the cwd filesystem using the destination file. */
int probe = open(dest, O_WRONLY | O_CREAT | O_TRUNC, 0600);
bool has_xattr = probe >= 0 && setxattr(dest, "user.fastsync.xprobe", "p", 1, 0) == 0;
if (probe >= 0)
close(probe);
if (!has_xattr) {
removexattr(dest, "user.fastsync.xprobe");
unlink(dest);
rmdir(basis_dir);
return; /* skip silently when the filesystem has no xattr support */
}
removexattr(dest, "user.fastsync.xprobe");
FileXattrList* xattrs = xattr_list_new();
EXPECT_NOT_NULL(xattrs);
EXPECT_TRUE(xattr_list_append(xattrs, "user.fallback", "kept", 4));
FileMetadata m;
memset(&m, 0, sizeof(m));
m.mode = 0640;
m.uid = 1001;
m.gid = 1002;
m.mtime_sec = 1234567890;
m.mtime_nsec = 0;
m.atime_valid = false;
m.crtime_valid = false;
bool ok = file_to_disk_secure_link_attrs(dest, basis_dir, "payload", 7, false, &m, false, false,
xattrs, true, NULL);
xattr_list_free(xattrs);
EXPECT_TRUE(ok);
/* Content landed (the copy fallback wrote the caller's bytes). */
int fd = open(dest, O_RDONLY);
EXPECT_TRUE(fd >= 0);
if (fd >= 0) {
char buf[16];
ssize_t n = read(fd, buf, sizeof(buf));
close(fd);
EXPECT_EQ_INT((int)strlen("payload"), (int)n);
if (n == 7)
EXPECT_TRUE(memcmp(buf, "payload", 7) == 0);
}
/* Per-file xattr applied on the copy. */
char vbuf[16];
ssize_t vlen = getxattr(dest, "user.fallback", vbuf, sizeof(vbuf));
EXPECT_EQ_INT(4, (int)vlen);
if (vlen == 4)
EXPECT_TRUE(memcmp(vbuf, "kept", 4) == 0);
/* fake-super stat parked by the receiver. */
EXPECT_TRUE((int)getxattr(dest, FAKESUPER_XATTR, NULL, 0) > 0);
unlink(dest);
rmdir(basis_dir);
}
void test_xattr() {
test_xattr_wire_roundtrip();
test_xattr_reject_privileged_namespace();
test_xattr_reject_oversized_value();
test_xattr_count_bound();
test_xattr_capture_and_appliable();
test_link_copy_fallback_preserves_xattrs();
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef TEST_XATTR_H
#define TEST_XATTR_H
void test_xattr(void);
#endif