diff --git a/RSYNC_COMPAT.md b/RSYNC_COMPAT.md index daef63e..b1dae47 100644 --- a/RSYNC_COMPAT.md +++ b/RSYNC_COMPAT.md @@ -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` diff --git a/src/client/client_cli.c b/src/client/client_cli.c index 6c3f695..2c5b0d2 100644 --- a/src/client/client_cli.c +++ b/src/client/client_cli.c @@ -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; } diff --git a/src/client/client_send.c b/src/client/client_send.c index 944bba1..1649788 100644 --- a/src/client/client_send.c +++ b/src/client/client_send.c @@ -20,6 +20,7 @@ #include "transport_ssh.h" #include "transport_tls.h" #include "utils.h" +#include "xattr.h" #include #include #include @@ -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; } diff --git a/src/client/client_validation.c b/src/client/client_validation.c index cf5d4fe..2451f57 100644 --- a/src/client/client_validation.c +++ b/src/client/client_validation.c @@ -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"); diff --git a/src/client/scanner.c b/src/client/scanner.c index 632ce43..af0da24 100644 --- a/src/client/scanner.c +++ b/src/client/scanner.c @@ -15,6 +15,8 @@ #include #include +#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); diff --git a/src/client/scanner.h b/src/client/scanner.h index 0655df4..c306c5f 100644 --- a/src/client/scanner.h +++ b/src/client/scanner.h @@ -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; diff --git a/src/client/usage.c b/src/client/usage.c index e6e7693..58c2396 100644 --- a/src/client/usage.c +++ b/src/client/usage.c @@ -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 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"); diff --git a/src/shared/config.c b/src/shared/config.c index 090821c..20ffc27 100644 --- a/src/shared/config.c +++ b/src/shared/config.c @@ -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) { diff --git a/src/shared/config.h b/src/shared/config.h index 2b801d4..6bc9619 100644 --- a/src/shared/config.h +++ b/src/shared/config.h @@ -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" diff --git a/src/shared/file.c b/src/shared/file.c index a33c72b..d7b4f46 100644 --- a/src/shared/file.c +++ b/src/shared/file.c @@ -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)) diff --git a/src/shared/file.h b/src/shared/file.h index 84878b9..9fb14b8 100644 --- a/src/shared/file.h +++ b/src/shared/file.h @@ -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 diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 6a9c3da..692cf6b 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -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); diff --git a/src/shared/file_send.c b/src/shared/file_send.c index 82234f3..18a1f27 100644 --- a/src/shared/file_send.c +++ b/src/shared/file_send.c @@ -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) { diff --git a/src/shared/file_send.h b/src/shared/file_send.h index 515abf1..183394e 100644 --- a/src/shared/file_send.h +++ b/src/shared/file_send.h @@ -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 diff --git a/src/shared/file_types.h b/src/shared/file_types.h index 89ce169..9bba5ba 100644 --- a/src/shared/file_types.h +++ b/src/shared/file_types.h @@ -2,6 +2,7 @@ #define FILE_TYPES_H #include "data.h" +#include "xattr.h" #include #include @@ -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 diff --git a/src/shared/xattr.c b/src/shared/xattr.c new file mode 100644 index 0000000..14f60bb --- /dev/null +++ b/src/shared/xattr.c @@ -0,0 +1,331 @@ +#define _GNU_SOURCE +#include "xattr.h" +#include "log.h" +#include "protocol.h" +#include "utils.h" +#include "file_types.h" +#include +#include +#include +#include +#include +#include + +/* ---- 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)); + } +} \ No newline at end of file diff --git a/src/shared/xattr.h b/src/shared/xattr.h new file mode 100644 index 0000000..c61c2d1 --- /dev/null +++ b/src/shared/xattr.h @@ -0,0 +1,89 @@ +#ifndef XATTR_H +#define XATTR_H + +#include +#include +#include + +/* + * 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 \ No newline at end of file diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index 082e961..fb853f0 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -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(" #include @@ -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); diff --git a/tests/test_client_cli.c b/tests/test_client_cli.c index dd328a4..758c528 100644 --- a/tests/test_client_cli.c +++ b/tests/test_client_cli.c @@ -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(); diff --git a/tests/test_config.c b/tests/test_config.c index 2bef127..6e6462c 100644 --- a/tests/test_config.c +++ b/tests/test_config.c @@ -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(); diff --git a/tests/test_xattr.c b/tests/test_xattr.c new file mode 100644 index 0000000..ffe95d3 --- /dev/null +++ b/tests/test_xattr.c @@ -0,0 +1,234 @@ +#include "test_xattr.h" +#include "xattr.h" +#include "file.h" +#include "protocol.h" +#include "test_utils.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(); +} \ No newline at end of file diff --git a/tests/test_xattr.h b/tests/test_xattr.h new file mode 100644 index 0000000..8252143 --- /dev/null +++ b/tests/test_xattr.h @@ -0,0 +1,6 @@ +#ifndef TEST_XATTR_H +#define TEST_XATTR_H + +void test_xattr(void); + +#endif \ No newline at end of file