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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user