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.
This commit is contained in:
2026-09-14 16:19:26 +02:00
parent df887c73b1
commit a2370433b2
12 changed files with 635 additions and 63 deletions
+13 -9
View File
@@ -75,7 +75,7 @@ static void write_best_effort(int fd, const void* data, size_t size) {
}
static void receive_stream(const unsigned char* prefix, size_t prefix_len, const uint8_t* data,
size_t size) {
size_t size, bool preserve_acls) {
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) != 0)
return;
@@ -91,7 +91,7 @@ static void receive_stream(const unsigned char* prefix, size_t prefix_len, const
shutdown(sv[0], SHUT_WR);
int ok = 0;
FileXattrList* list = xattr_receive(sv[1], &ok);
FileXattrList* list = xattr_receive(sv[1], &ok, preserve_acls);
xattr_list_free(list);
close(sv[0]);
@@ -102,14 +102,18 @@ int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
if (!g_block_ready)
build_canonical_block();
/* Raw bytes as the whole block. */
receive_stream(NULL, 0, data, size);
/* Raw bytes as the whole block. Exercise both the -X-only (no ACLs) and the
* -A (ACL names accepted) receiver gates. */
for (int acls = 0; acls < 2; acls++) {
bool preserve_acls = acls != 0;
receive_stream(NULL, 0, data, size, preserve_acls);
/* Valid framing so the fuzzer mutates the entry list, the first value and
* the second entry respectively instead of stopping at the count. */
receive_stream(g_block, g_off_after_entry0, data, size);
receive_stream(g_block, g_off_value0, data, size);
receive_stream(g_block, g_off_after_count, data, size);
/* Valid framing so the fuzzer mutates the entry list, the first value and
* the second entry respectively instead of stopping at the count. */
receive_stream(g_block, g_off_after_entry0, data, size, preserve_acls);
receive_stream(g_block, g_off_value0, data, size, preserve_acls);
receive_stream(g_block, g_off_after_count, data, size, preserve_acls);
}
return 0;
}
+37 -6
View File
@@ -444,9 +444,10 @@ class TestRemoteDryRun:
self._seed(source)
clean_dir(dest)
# Populate the destination with a real transfer, then make exactly one
# file differ (content+size) and add a brand-new file.
result, _ = run_client(source, dest, port=shared_server.port)
# Populate the destination with a real transfer that preserves mtimes
# (--preserve), then make exactly one file differ (content+size) and add
# a brand-new file.
result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, f"seed transfer failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
@@ -456,9 +457,11 @@ class TestRemoteDryRun:
f.write(b"newly added\n")
before = _snapshot_tree(received)
# --checksum makes the up-to-date decision content-based (the seed
# transfer did not preserve mtimes), so keep.txt/deep.txt report skip.
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum"],
# --checksum must NOT read destination contents in a dry-run (B3), so
# the up-to-date decision is metadata-only. The --preserve seed made
# keep.txt and deep.txt size+mtime-identical; the dry-run must also
# transmit metadata (--preserve) for that metadata to be comparable.
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum", "--preserve"],
port=shared_server.port)
assert result.returncode == 0, f"remote dry-run failed: {result.stderr[:300]}"
assert "Dry run:" in result.stdout, result.stdout[:200]
@@ -470,6 +473,34 @@ class TestRemoteDryRun:
assert "deep.txt" not in result.stdout, result.stdout
assert _snapshot_tree(received) == before, "remote dry-run mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_checksum_does_not_read_destination(self, shared_server):
"""B3: --dry-run --checksum against a read-only module must not read the
destination file's content (a 1-bit hash oracle). A same-size/same-content
file whose mtime differs is therefore reported as would-transfer because
the metadata-only decision is inconclusive, instead of being hashed and
silently skipped."""
source = os.path.join(TEST_DATA_DIR, "remote_dry_oracle_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_oracle_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
target = os.path.join(received, "keep.txt")
# Identical size and content, but a deliberately different mtime.
os.utime(target, (1000000000, 1000000000))
before = _snapshot_tree(received)
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum", "--preserve"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert "keep.txt" in result.stdout, (
f"dry-run --checksum must not read the destination to prove equality: {result.stdout}"
)
assert _snapshot_tree(received) == before, "dry-run mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_into_empty_dest_creates_nothing(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_empty_src")
+48
View File
@@ -1,8 +1,10 @@
#include "chunk.h"
#include "protocol.h"
#include "test_utils.h"
#include "utils.h"
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
@@ -279,6 +281,51 @@ static void test_chunk_special_rdev_out_of_range_rejected() {
chunk_destroy(chunk);
}
/* B6: chunk_deserialize() charges each retained per-file copy to the owning
* session's connection budget (MAX_CONNECTION_MEMORY) so queued chunk payloads
* are not held outside the per-connection ceiling; destroying the chunk returns
* the charge through the Data.owner path. */
static void test_chunk_deserialize_charges_session_budget() {
const char* path = "temp_chunk_charge.txt";
const char* content = "charge me to the connection budget";
unlink(path);
file_write_to_disk(path, content, strlen(content), false, false);
struct stat st;
EXPECT_EQ_INT(stat(path, &st), 0);
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
ProtocolSession session;
protocol_session_init(&session, p[0], p[1]);
protocol_session_set_max_alloc(&session, 4ULL * 1024 * 1024);
File* f = file_create(path);
EXPECT_NOT_NULL(f);
f->data->size = (unsigned long long)st.st_size;
EXPECT_TRUE(file_load_data(f));
File* files[1] = {f};
Chunk* chunk = chunk_create(files, 1);
EXPECT_NOT_NULL(chunk);
Data* serialized = chunk_serialize(chunk, false);
EXPECT_NOT_NULL(serialized);
/* Simulate a received buffer carrying its owning session. */
serialized->owner = &session;
Chunk* deserialized = chunk_deserialize(serialized, false);
EXPECT_NOT_NULL(deserialized);
unsigned long long charged = atomic_load(&session.total_allocated_bytes);
EXPECT_EQ_INT((int)charged, (int)strlen(content));
chunk_destroy(deserialized);
/* The copy's charge is released with the File/Data on destroy. */
EXPECT_EQ_INT((int)atomic_load(&session.total_allocated_bytes), 0);
data_destroy(serialized);
chunk_destroy(chunk);
close(p[0]);
close(p[1]);
unlink(path);
}
void test_chunk() {
test_file_operations();
test_chunk_operations();
@@ -286,4 +333,5 @@ void test_chunk() {
test_chunk_symlink_roundtrip();
test_chunk_special_rdev_roundtrip();
test_chunk_special_rdev_out_of_range_rejected();
test_chunk_deserialize_charges_session_budget();
}
+91
View File
@@ -14,6 +14,7 @@
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/sysmacros.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
@@ -994,6 +995,94 @@ static void test_inplace_overwrite_truncates_shorter_payload() {
rmdir(root);
}
/* B2: --inplace must refuse an existing non-regular destination entry. A FIFO
would block open(O_WRONLY) forever and a device node would be written
directly, bypassing the --write-devices/super gate. Forked with an alarm so
a regression is a prompt failure instead of a hung suite. */
static void test_inplace_refuses_fifo_destination() {
const char* root = "test_inplace_fifo_tmp";
const char* path = "test_inplace_fifo_tmp/fifo";
unlink(path);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
EXPECT_EQ_INT(mkfifo(path, 0600), 0);
pid_t pid = fork();
if (pid == 0) {
alarm(10);
File* f = file_create("fifo");
if (!f)
_exit(1);
const char* content = "payload";
f->data->data = malloc(strlen(content));
if (!f->data->data)
_exit(1);
memcpy(f->data->data, content, strlen(content));
f->data->size = strlen(content);
Config* cfg = config_create();
if (!cfg)
_exit(1);
cfg->inplace = true;
bool written = file_save_to_disk(root, f, cfg);
file_destroy(f);
config_delete(cfg);
_exit(written ? 1 : 0); /* must be refused */
}
int status;
waitpid(pid, &status, 0);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
struct stat st;
EXPECT_EQ_INT(lstat(path, &st), 0);
EXPECT_TRUE(S_ISFIFO(st.st_mode)); /* left untouched */
unlink(path);
rmdir(root);
}
/* B2: an existing char device must not be written by --inplace. mknod needs
privilege, so a non-root run skips gracefully. /dev/null's (1:3) rdev makes
the negative case harmless if it ever regresses. */
static void test_inplace_refuses_device_destination() {
const char* root = "test_inplace_dev_tmp";
const char* path = "test_inplace_dev_tmp/dev";
unlink(path);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
if (mknod(path, S_IFCHR | 0600, makedev(1, 3)) != 0) {
rmdir(root);
return; /* no privilege to create a device node: skip */
}
pid_t pid = fork();
if (pid == 0) {
alarm(10);
File* f = file_create("dev");
if (!f)
_exit(1);
const char* content = "payload";
f->data->data = malloc(strlen(content));
if (!f->data->data)
_exit(1);
memcpy(f->data->data, content, strlen(content));
f->data->size = strlen(content);
Config* cfg = config_create();
if (!cfg)
_exit(1);
cfg->inplace = true;
bool written = file_save_to_disk(root, f, cfg);
file_destroy(f);
config_delete(cfg);
_exit(written ? 1 : 0); /* must be refused */
}
int status;
waitpid(pid, &status, 0);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
struct stat st;
EXPECT_EQ_INT(lstat(path, &st), 0);
EXPECT_TRUE(S_ISCHR(st.st_mode)); /* still a device, not replaced */
unlink(path);
rmdir(root);
}
/* Explicit directory entries (--dirs) create the directory under the receive
root through the same save funnel, creating parents as needed, and reject
traversal the same way a file path does. */
@@ -1606,4 +1695,6 @@ void test_file() {
test_inplace_overwrite_clears_special_mode_bits();
test_inplace_overwrite_metadata_strips_special_bits();
test_inplace_overwrite_truncates_shorter_payload();
test_inplace_refuses_fifo_destination();
test_inplace_refuses_device_destination();
}
+169
View File
@@ -844,6 +844,172 @@ static void test_special_socket_path_log_escaped() {
EXPECT_NOT_NULL(strstr(output, "socket not recreated: evil\\#012path"));
}
/* B1: a client-planted FIFO at the destination must not block the receiver's
* incremental-check open. With the O_NONBLOCK open plus the post-open S_ISREG
* gate the FIFO is simply "no existing regular file", so the receiver proceeds
* to a full transfer; without O_NONBLOCK the child blocks in openat() and the
* alarm(30) kills it. */
static void test_incremental_check_fifo_destination_does_not_hang() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
char* root = make_check_root("qffo");
EXPECT_NOT_NULL(root);
cfg->receive_root_directory = str_dup(root);
char path[1024];
snprintf(path, sizeof(path), "%s/file.txt", root);
EXPECT_EQ_INT(mkfifo(path, 0600), 0);
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) {
alarm(30);
close(p[1]);
io_set_fds(p[0], p[0]);
bool skipped = false;
File* file = receive_incremental_check(p[0], cfg, &skipped);
bool ok = file != NULL && !skipped;
file_destroy(file);
config_delete(cfg);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
EXPECT_TRUE(send_str(p[1], "file.txt"));
unsigned long long size = 4;
long long mtime = 42;
long long mtime_nsec = 0;
EXPECT_TRUE(send_n_data(p[1], &size, sizeof(size)));
EXPECT_TRUE(send_n_data(p[1], &mtime, sizeof(mtime)));
EXPECT_TRUE(send_n_data(p[1], &mtime_nsec, sizeof(mtime_nsec)));
Status s;
EXPECT_TRUE(receive_status(p[1], &s));
EXPECT_EQ_INT(s, STATUS_NEXT);
Data* body = data_create_reserve(4);
EXPECT_NOT_NULL(body);
body->data = malloc(4);
EXPECT_NOT_NULL(body->data);
memcpy(body->data, "data", 4);
body->size = 4;
EXPECT_TRUE(send_data(p[1], body));
data_destroy(body);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(cfg);
unlink(path);
rmdir(root);
free(root);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* B1: a FIFO planted in a --link-dest basis directory must not block
* basis_open_regular() either; the basis match is simply declined. */
static void test_incremental_check_basis_fifo_does_not_hang() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
char* root = make_check_root("qbfi");
EXPECT_NOT_NULL(root);
cfg->receive_root_directory = str_dup(root);
char basis_dir[1024];
char basis_path[2048];
snprintf(basis_dir, sizeof(basis_dir), "%s/basis", root);
EXPECT_EQ_INT(mkdir(basis_dir, 0700), 0);
snprintf(basis_path, sizeof(basis_path), "%s/file.txt", basis_dir);
EXPECT_EQ_INT(mkfifo(basis_path, 0600), 0);
EXPECT_EQ_INT(config_basis_append(cfg, BASIS_DEST_LINK, "basis"), 0);
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) {
alarm(30);
close(p[1]);
io_set_fds(p[0], p[0]);
bool skipped = false;
File* file = receive_incremental_check(p[0], cfg, &skipped);
bool ok = file != NULL && !skipped;
file_destroy(file);
config_delete(cfg);
close(p[0]);
_exit(ok ? 0 : 1);
} else {
close(p[0]);
io_set_fds(p[1], p[1]);
EXPECT_TRUE(send_str(p[1], "file.txt"));
unsigned long long size = 4;
long long mtime = 42;
long long mtime_nsec = 0;
EXPECT_TRUE(send_n_data(p[1], &size, sizeof(size)));
EXPECT_TRUE(send_n_data(p[1], &mtime, sizeof(mtime)));
EXPECT_TRUE(send_n_data(p[1], &mtime_nsec, sizeof(mtime_nsec)));
/* config_has_basis() makes the request carry the source digest. */
uint8_t wire_len = 8;
uint8_t digest[8] = {0};
EXPECT_TRUE(send_n_data(p[1], &wire_len, sizeof(wire_len)));
EXPECT_TRUE(send_n_data(p[1], digest, sizeof(digest)));
Status s;
EXPECT_TRUE(receive_status(p[1], &s));
EXPECT_EQ_INT(s, STATUS_NEXT);
Data* body = data_create_reserve(4);
EXPECT_NOT_NULL(body);
body->data = malloc(4);
EXPECT_NOT_NULL(body->data);
memcpy(body->data, "data", 4);
body->size = 4;
EXPECT_TRUE(send_data(p[1], body));
data_destroy(body);
int status;
waitpid(pid, &status, 0);
close(p[1]);
config_delete(cfg);
unlink(basis_path);
rmdir(basis_dir);
rmdir(root);
free(root);
EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0);
}
}
/* B5: the aggregate entry count across the three manifest sections is capped at
* MAX_MANIFEST_ENTRIES, and a section that would push the total over the cap is
* rejected before its entries are read (so a tiny first section followed by a
* huge claimed second section fails fast). */
static void test_receive_manifest_total_entry_cap() {
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->receive_root_directory = str_dup("/tmp/dst");
int p[2];
EXPECT_EQ_INT(socketpair(AF_UNIX, SOCK_STREAM, 0, p), 0);
io_set_fds(p[0], p[1]);
EXPECT_TRUE(send_int(p[1], 1));
EXPECT_TRUE(send_str(p[1], "keep.txt"));
/* The second section alone is within its per-section cap, but 1 + it exceeds
the cross-section cap; the receiver must reject at the count. */
EXPECT_TRUE(send_int(p[1], MAX_MANIFEST_ENTRIES));
EXPECT_NULL(receive_manifest_entries(p[0]));
Status status;
EXPECT_TRUE(receive_status(p[1], &status));
EXPECT_EQ_INT(status, STATUS_ERROR);
close(p[0]);
close(p[1]);
config_delete(cfg);
}
void test_server() {
test_special_socket_path_log_escaped();
if (!is_running_under_valgrind()) {
@@ -856,6 +1022,9 @@ void test_server() {
test_incremental_check_size_mismatch_full_transfer();
test_incremental_check_dry_run_reports_transfer_without_writing();
test_incremental_check_delta_oversize_reports_failure();
test_incremental_check_fifo_destination_does_not_hang();
test_incremental_check_basis_fifo_does_not_hang();
test_receive_manifest_total_entry_cap();
test_late_manifest_abort_frees_keepset();
test_late_manifest_eof_frees_keepset();
test_late_second_manifest_frees_both();
+60 -10
View File
@@ -17,7 +17,7 @@
static void run_recv_helper(int fd) {
int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok);
FileXattrList* list = xattr_receive(fd, &ok, false);
if (!ok)
_exit(1);
if (!list) {
@@ -64,7 +64,7 @@ static void test_xattr_wire_roundtrip() {
static void run_recv_must_fail(int fd) {
int ok = 0;
FileXattrList* list = xattr_receive(fd, &ok);
FileXattrList* list = xattr_receive(fd, &ok, false);
/* A NULL list with ok==0 is the expected rejection. */
if (ok == 0 && list == NULL)
_exit(0);
@@ -148,15 +148,64 @@ static void test_xattr_count_bound() {
/* 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"));
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"));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_access"));
EXPECT_TRUE(xattr_name_appliable("system.posix_acl_default"));
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
@@ -360,6 +409,7 @@ void test_xattr() {
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();