Files
FastSync/tests/test_xattr.c
T
TapTap a2370433b2 fix(receiver): non-blocking receiver opens, inplace type gate, dry-run/B4/B5/B6
Address confirmed receiver security findings B1-B6:

B1 (HIGH): add O_NONBLOCK to the three receiver read-opens that opened an
existing destination/basis entry before the S_ISREG gate
(incremental_check_open_destination, basis_open_regular, hardlink_read_source)
so a client-planted FIFO can no longer block the receive thread forever while
the post-open type gate still rejects it.

B2 (HIGH/MED): --inplace now fstatat(AT_SYMLINK_NOFOLLOW)-probes the target and
refuses any existing non-regular entry, opens with O_NONBLOCK, and re-checks
S_ISREG on the opened fd.  This stops a FIFO from hanging the open and stops a
char/block device from being written directly (bypassing --write-devices).

B3 (MED): under --dry-run the incremental quick-skip no longer reads/hashes the
destination file for --checksum/--delta; it decides from metadata only and
reports would-transfer when the comparison is inconclusive, closing the
read-only-module content-hash oracle.

B4 (LOW): xattr_name_appliable() now gates the two system.posix_acl_* names on
preserve_acls (--acls), not the derived use_xattrs (--xattrs OR --acls).  The
receiver drops (never applies) ACL entries when -A was not negotiated while
keeping user.* working for -X.

B5 (INFO): receive_manifest_section() charges a per-entry overhead against
MAX_MANIFEST_BYTES and the aggregate entry count across all three sections is
capped at MAX_MANIFEST_ENTRIES.

B6 (MED): data_charge_session() reserves decompressed/chunk-copy bytes against
the owning ProtocolSession (MAX_CONNECTION_MEMORY) and records them on the Data
so data_destroy() releases them via the Data.owner path.  Applied to the
whole-file/append/delta decompression sites and chunk_deserialize() per-file
copies; a missing session owner degrades to the previous uncharged behavior.

Tests: FIFO destination/basis non-hang (with alarm), --inplace FIFO/device
refusal, dry-run no-read oracle test plus updated metadata-only dry-run tests,
ACL-without--acls drop, manifest total-entry cap, and chunk session charging.
2026-09-14 16:19:26 +02:00

416 lines
14 KiB
C

#include "test_xattr.h"
#include "xattr.h"
#include "config.h"
#include "file.h"
#include "identity.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, false);
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, false);
/* 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, false));
EXPECT_FALSE(xattr_name_appliable("", false));
EXPECT_FALSE(xattr_name_appliable("security.selinux", false));
EXPECT_FALSE(xattr_name_appliable("trusted.blob", false));
EXPECT_TRUE(xattr_name_appliable("user.foo", false));
EXPECT_TRUE(xattr_name_appliable("user.foo", true));
/* The reserved fake-super key is receiver-only and never forwarded/applied. */
EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat", false));
EXPECT_FALSE(xattr_name_appliable("user.fastsync.stat", true));
/* B4: the ACL names require --acls; -X alone must not authorize them. */
EXPECT_FALSE(xattr_name_appliable("system.posix_acl_access", false));
EXPECT_FALSE(xattr_name_appliable("system.posix_acl_default", false));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_access", true));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_default", true));
}
/* B4: a `-X`-only receiver (preserve_acls false) must NOT apply an incoming
* ACL xattr, while a user.* attribute in the same block still survives. The
* ACL entry is dropped, not applied (and the -X transfer is not failed). */
static void run_recv_drops_acl_keeps_user(int fd) {
int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok, false);
if (!ok || list == NULL)
_exit(1);
bool saw_user = false;
for (int i = 0; i < list->count; i++) {
if (strcmp(list->items[i].name, "system.posix_acl_access") == 0)
_exit(1); /* ACL must have been dropped */
if (strcmp(list->items[i].name, "user.keep") == 0)
saw_user = true;
}
xattr_list_free(list);
_exit(saw_user ? 0 : 1);
}
static void test_xattr_receive_drops_acl_without_preserve_acls() {
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_drops_acl_keeps_user(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, "system.posix_acl_access", "\x02\x00\x00\x00", 4));
EXPECT_TRUE(xattr_list_append(list, "user.keep", "yes", 3));
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);
}
/* 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);
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);
}
/* --fake-super replay: fake_super_store_fd records the source stat into the
* reserved xattr, and fake_super_restore_fd re-applies mode/mtime (and owner,
* when the process may) fd-relative. Restore must also be a safe no-op with no
* xattr present. Guarded on filesystem xattr support. */
static void test_fake_super_restore() {
const char* path = "test_fake_super_restore.txt";
unlink(path);
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0)
return;
bool has_xattr = setxattr(path, "user.fastsync.xprobe", "p", 1, 0) == 0;
if (has_xattr)
removexattr(path, "user.fastsync.xprobe");
if (!has_xattr) {
close(fd);
unlink(path);
return; /* skip silently when the filesystem has no xattr support */
}
/* No xattr present yet: restore is a silent no-op (returns false, no crash). */
EXPECT_FALSE(fake_super_restore_fd(fd));
fake_super_store_fd(fd, 1001, 1002, 0751, 1700000000, 123456789);
EXPECT_TRUE(fake_super_restore_fd(fd));
struct stat st;
EXPECT_EQ_INT(fstat(fd, &st), 0);
EXPECT_EQ_INT((int)(st.st_mode & 07777), 0751);
/* Mode sanitization: the normal metadata path never grants group/other write
bits, and fake-super replay must not re-add them (a recorded 0666 restores
as 0644, never as world-writable). */
fake_super_store_fd(fd, 1001, 1002, 0666, 1700000000, 0);
EXPECT_TRUE(fake_super_restore_fd(fd));
EXPECT_EQ_INT(fstat(fd, &st), 0);
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0644);
/* Restore with a malformed record must skip without failing. */
time_t before = st.st_mtime;
int wfd = open(path, O_RDONLY);
if (wfd >= 0) {
EXPECT_EQ_INT((int)fsetxattr(wfd, FAKESUPER_XATTR, "not-a-valid-record", 19, 0), 0);
close(wfd);
}
EXPECT_FALSE(fake_super_restore_fd(fd));
fstat(fd, &st);
EXPECT_EQ_INT((int)st.st_mtime, (int)before);
close(fd);
unlink(path);
}
/* --fake-super owner replay must honor the super gate and copy-as authority:
--no-super suppresses the recorded-source-owner chown even for root, and an
active --copy-as keeps its forced owner (the recorded source owner must never
override it). Root-gated: only root can observe a chown actually landing. */
static void test_fake_super_owner_gate() {
if (geteuid() != 0)
return; /* non-root cannot observe ownership changes; skip silently */
const char* path = "test_fake_super_owner_gate.txt";
unlink(path);
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0)
return;
bool has_xattr = setxattr(path, "user.fastsync.xprobe", "p", 1, 0) == 0;
if (has_xattr)
removexattr(path, "user.fastsync.xprobe");
if (!has_xattr) {
close(fd);
unlink(path);
return; /* filesystem without xattr support */
}
if (fchown(fd, 0, 0) != 0) {
close(fd);
unlink(path);
return;
}
fake_super_store_fd(fd, 12345, 12346, 0755, 1700000000, 0);
Config* c = config_create();
EXPECT_NOT_NULL(c);
/* An explicit ownership policy is required before fake-super replay may
chown; --fake-super alone only records the source owner (A2). */
c->numeric_ids = true;
/* --no-super: the owner leg is skipped even as root. */
c->super_mode = SUPER_MODE_OFF;
EXPECT_TRUE(identity_set_active(c));
EXPECT_TRUE(fake_super_restore_fd(fd));
struct stat st;
EXPECT_EQ_INT(fstat(fd, &st), 0);
EXPECT_EQ_INT((int)st.st_uid, 0);
EXPECT_EQ_INT((int)st.st_gid, 0);
/* AUTO with an identity policy: the recorded source owner is applied. */
c->super_mode = SUPER_MODE_AUTO;
EXPECT_TRUE(identity_set_active(c));
EXPECT_TRUE(fake_super_restore_fd(fd));
EXPECT_EQ_INT(fstat(fd, &st), 0);
EXPECT_EQ_INT((int)st.st_uid, 12345);
EXPECT_EQ_INT((int)st.st_gid, 12346);
/* --super / --fake-super with NO explicit identity flag must NOT apply a
client-chosen owner: super_mode alone never enables ownership. */
EXPECT_EQ_INT(fchown(fd, 0, 0), 0);
c->numeric_ids = false;
c->super_mode = SUPER_MODE_ON;
EXPECT_TRUE(identity_set_active(c));
EXPECT_TRUE(fake_super_restore_fd(fd));
EXPECT_EQ_INT(fstat(fd, &st), 0);
EXPECT_EQ_INT((int)st.st_uid, 0);
EXPECT_EQ_INT((int)st.st_gid, 0);
/* Active --copy-as is authoritative: the recorded source owner must not
override it, even with AUTO/ON. */
c->copy_as_set = true;
c->copy_as_uid = 777;
c->copy_as_gid = 778;
EXPECT_TRUE(identity_set_active(c));
EXPECT_TRUE(fake_super_restore_fd(fd));
EXPECT_EQ_INT(fstat(fd, &st), 0);
EXPECT_EQ_INT((int)st.st_uid, 0);
EXPECT_EQ_INT((int)st.st_gid, 0);
identity_clear_active();
config_delete(c);
close(fd);
unlink(path);
}
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_xattr_receive_drops_acl_without_preserve_acls();
test_link_copy_fallback_preserves_xattrs();
test_fake_super_restore();
test_fake_super_owner_gate();
}