fix(xattr): fake-super device round-trip, --devices continue-on-error, harden stat parse

This commit is contained in:
2026-09-22 23:05:40 +02:00
parent 0b40c47d6a
commit 47b1b9b915
12 changed files with 310 additions and 47 deletions
@@ -64,6 +64,13 @@ def test_read_batch_absolute_temp_dir_inside_root_accepted(tmp_path):
clean_dir(dest)
scratch = os.path.join(dest, "scratch")
os.makedirs(scratch)
# Stamp the scratch dir with an old mtime so the test can prove the receiver
# really created (and then removed) its temp file there: the directory mtime
# changes when an entry is created/removed, so a silently-ignored --temp-dir
# would leave the stamp untouched. An empty scratch dir alone does not
# distinguish "used and cleaned up" from "never used".
stale_mtime = 946684800 # 2000-01-01
os.utime(scratch, (stale_mtime, stale_mtime))
batch = _make_batch(str(tmp_path), source)
result = _run(["--read-batch", batch, dest, "--temp-dir", scratch])
@@ -73,6 +80,9 @@ def test_read_batch_absolute_temp_dir_inside_root_accepted(tmp_path):
for rel, data in FILES.items():
assert _read(os.path.join(received, rel)) == data, f"content mismatch for {rel}"
assert os.listdir(scratch) == [], "scratch dir was not left clean"
assert os.stat(scratch).st_mtime != stale_mtime, (
"--temp-dir scratch dir was never written to (temp file not created there)"
)
@pytest.mark.ci
@@ -98,6 +108,11 @@ def test_tcp_absolute_temp_dir_inside_root_accepted(shared_server):
clean_dir(dest)
scratch = os.path.join(dest, "scratch")
os.makedirs(scratch)
# See test_read_batch_absolute_temp_dir_inside_root_accepted: the stale
# mtime makes actual scratch usage observable (the temp file creation and
# removal bump the directory mtime).
stale_mtime = 946684800 # 2000-01-01
os.utime(scratch, (stale_mtime, stale_mtime))
result, _ = run_client(source, dest, flags=["--temp-dir", scratch], port=shared_server.port)
assert result.returncode == 0, (result.stdout, result.stderr)[:300]
@@ -105,6 +120,9 @@ def test_tcp_absolute_temp_dir_inside_root_accepted(shared_server):
for rel, data in FILES.items():
assert _read(os.path.join(received, rel)) == data, f"content mismatch for {rel}"
assert os.listdir(scratch) == [], "scratch dir was not left clean"
assert os.stat(scratch).st_mtime != stale_mtime, (
"--temp-dir scratch dir was never written to (temp file not created there)"
)
def test_tcp_absolute_temp_dir_outside_root_rejected(shared_server):
+104 -1
View File
@@ -9,7 +9,9 @@
#include "charset.h"
#include "utils.h"
#include "protocol.h"
#include "xattr.h"
#include "test_utils.h"
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <stdlib.h>
@@ -17,6 +19,7 @@
#include <sys/stat.h>
#include <sys/sysmacros.h>
#include <sys/wait.h>
#include <sys/xattr.h>
#include <time.h>
#include <unistd.h>
@@ -348,7 +351,8 @@ static void test_file_save_to_disk_temp_dir_confined() {
mkdir(root, 0755);
mkdir("test_temp_confine_tmp/scratch", 0755);
mkdir("test_temp_confine_tmp/abs_scratch", 0755);
EXPECT_NOT_NULL(realpath("test_temp_confine_tmp/abs_scratch", inside_abs));
if (!realpath("test_temp_confine_tmp/abs_scratch", inside_abs))
EXPECT_FAIL("realpath(abs_scratch) failed; inside_abs would be uninitialized");
mkdir(outside, 0755);
File* f = file_create("file.txt");
@@ -1327,6 +1331,103 @@ static void test_special_socket_recreated() {
rmdir(root);
}
/* --fake-super device round-trip (rsync parity): a char/block device must be
* materialized as a REGULAR empty file whose user.rsync.%stat records the real
* rdev -- never as an mknod'ed node -- even on a privileged receiver. This is
* the non-privileged unit counterpart to the setpriv integration test (which
* the PR gate excludes). */
static void test_fake_super_device_writes_regular_file_with_rdev() {
const char* root = "test_fake_super_dev_tmp";
const char* node = "test_fake_super_dev_tmp/cdev";
unlink(node);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0700), 0);
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->fake_super = true;
cfg->preserve_devices = true;
cfg->preserve_perms = true;
cfg->use_metadata = true;
cfg->use_xattrs = true;
FileMetadata meta;
memset(&meta, 0, sizeof(meta));
meta.mode = S_IFCHR | 0644;
File* f = file_create("cdev");
EXPECT_NOT_NULL(f);
f->is_special = true;
f->rdev_major = 1;
f->rdev_minor = 3;
f->metadata = &meta;
EXPECT_EQ_INT(file_save_to_disk_full(root, f, cfg), FILE_SAVE_WRITTEN);
struct stat st;
EXPECT_EQ_INT(lstat(node, &st), 0);
EXPECT_TRUE(S_ISREG(st.st_mode)); /* never a real device node */
EXPECT_EQ_INT((int)st.st_size, 0);
char value[128] = {0};
ssize_t got = getxattr(node, FAKESUPER_XATTR, value, sizeof(value) - 1);
EXPECT_TRUE(got > 0);
EXPECT_EQ_STR(value, "20644 1,3 0:0"); /* the REAL rdev, not 0,0 */
f->metadata = NULL;
file_destroy(f);
config_delete(cfg);
unlink(node);
rmdir(root);
}
/* A char/block device that mknodat() refuses (EPERM/EACCES on an unprivileged
* receiver) must be a PER-ENTRY failure -- FILE_SAVE_FAILED, which the receiver
* counts and continues past -- never the fatal FILE_SAVE_ERROR that aborts the
* stream. The unit suite normally runs as root, so drop the effective uid to
* make the kernel refusal deterministic. */
static void test_device_mknod_failure_is_per_entry() {
const char* root = "test_device_eperm_tmp";
const char* node = "test_device_eperm_tmp/cdev";
unlink(node);
rmdir(root);
EXPECT_EQ_INT(mkdir(root, 0777), 0);
Config* cfg = config_create();
EXPECT_NOT_NULL(cfg);
cfg->preserve_devices = true;
cfg->use_metadata = true;
FileMetadata meta;
memset(&meta, 0, sizeof(meta));
meta.mode = S_IFCHR | 0644;
File* f = file_create("cdev");
EXPECT_NOT_NULL(f);
f->is_special = true;
f->rdev_major = 1;
f->rdev_minor = 3;
f->metadata = &meta;
uid_t saved = geteuid();
bool dropped = false;
if (saved == 0 && seteuid(65534) == 0)
dropped = true;
FileSaveResult result = file_save_to_disk_full(root, f, cfg);
if (dropped)
EXPECT_EQ_INT(seteuid(saved), 0);
EXPECT_EQ_INT(result, FILE_SAVE_FAILED);
/* Nothing was created: no device node and no regular-file fallback. */
struct stat st;
EXPECT_EQ_INT(lstat(node, &st), -1);
f->metadata = NULL;
file_destroy(f);
config_delete(cfg);
rmdir(root);
}
static void test_inplace_overwrite_truncates_shorter_payload() {
const char* root = "test_inplace_trunc_tmp";
const char* path = "test_inplace_trunc_tmp/big.txt";
@@ -2408,6 +2509,8 @@ void test_file() {
test_new_file_mode_honors_source_and_umask();
test_special_fifo_mode_honors_source_and_umask();
test_special_socket_recreated();
test_fake_super_device_writes_regular_file_with_rdev();
test_device_mknod_failure_is_per_entry();
test_inplace_overwrite_truncates_shorter_payload();
test_inplace_refuses_fifo_destination();
test_inplace_refuses_device_destination();
+18
View File
@@ -450,6 +450,24 @@ static void test_fake_super_rsync_format() {
EXPECT_EQ_INT((int)st.st_uid, (int)before.st_uid);
EXPECT_EQ_INT((int)st.st_gid, (int)before.st_gid);
/* Hardened parser: an out-of-range field (previously UB via sscanf("%u")),
a missing field, or trailing garbage is rejected cleanly instead of being
silently accepted. */
const char* malformed[] = {
"20644 65536,3 111:222", /* major > 0xffff */
"20644 1,16777216 111:222", /* minor > 0xffffff */
"20644 1,3 111:222 trailing", /* trailing garbage */
"20644 1,3 111", /* missing gid */
"20644 1,3 4294967296:222", /* uid > UINT_MAX */
"20644 1,3 111:4294967296", /* gid > UINT_MAX */
"99999999999999999999 1,3 0:0", /* mode overflow */
"", /* empty record */
};
for (size_t i = 0; i < sizeof(malformed) / sizeof(malformed[0]); i++) {
EXPECT_EQ_INT((int)fsetxattr(fd, FAKESUPER_XATTR, malformed[i], strlen(malformed[i]), 0), 0);
EXPECT_FALSE(fake_super_restore_fd(fd, policy));
}
close(fd);
unlink(path);
}