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:
+51
-3
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#ifndef TEST_XATTR_H
|
||||
#define TEST_XATTR_H
|
||||
|
||||
void test_xattr(void);
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user