Merge feat/p4-identity-mapping: --numeric-ids / --usermap / --groupmap / --chown

This commit is contained in:
2026-09-08 18:23:52 +02:00
12 changed files with 1094 additions and 8 deletions
+86
View File
@@ -3695,3 +3695,89 @@ class TestFuzzy:
assert proxy.client_to_server > len(new_bytes) // 2, \
"--no-fuzzy should leave the default whole-file behavior intact"
class TestIdentityMapping:
"""Ownership-application flags (--numeric-ids / --usermap / --groupmap /
--chown). In CI the receiver usually runs unprivileged, so ownership apply
is expected to fail from lack of privilege: the transfer must STILL succeed
and exit 0 (the receiver warns and continues, rsync parity). The only
assertion that requires the ownership to actually change is gated on
os.geteuid() == 0 so it is skipped (not failed) as a non-root user."""
def test_numeric_ids_transfer_succeeds_unprivileged(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "identity_num_source")
dest = os.path.join(TEST_DATA_DIR, "identity_num_dest")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "f.txt"), "wb") as f:
f.write(b"hello identity")
result, _ = run_client(source, dest,
flags=["-M", "--numeric-ids"],
port=shared_server.port)
assert result.returncode == 0, \
f"exit {result.returncode}: {(result.stderr or '')[:200]}"
received = get_dest_received_dir(dest, source)
with open(os.path.join(received, "f.txt"), "rb") as f:
assert f.read() == b"hello identity"
def test_usermap_and_groupmap_and_chown_succeed_unprivileged(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "identity_map_source")
dest = os.path.join(TEST_DATA_DIR, "identity_map_dest")
clean_dir(source)
clean_dir(dest)
with open(os.path.join(source, "f.txt"), "wb") as f:
f.write(b"mapped")
result, _ = run_client(
source, dest,
flags=["-M", "--usermap=@1000:@1001", "--groupmap=@100:@101", "--chown=@2000:@2001"],
port=shared_server.port)
assert result.returncode == 0, \
f"exit {result.returncode}: {(result.stderr or '')[:200]}"
received = get_dest_received_dir(dest, source)
with open(os.path.join(received, "f.txt"), "rb") as f:
assert f.read() == b"mapped"
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
def test_numeric_ids_applies_ownership_as_root(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "identity_root_source")
dest = os.path.join(TEST_DATA_DIR, "identity_root_dest")
clean_dir(source)
clean_dir(dest)
src_file = os.path.join(source, "f.txt")
with open(src_file, "wb") as f:
f.write(b"owner")
os.chown(src_file, 12345, 12346)
result, _ = run_client(source, dest,
flags=["-M", "--numeric-ids"],
port=shared_server.port)
assert result.returncode == 0, \
f"exit {result.returncode}: {(result.stderr or '')[:200]}"
received = get_dest_received_dir(dest, source)
dst_file = os.path.join(received, "f.txt")
assert os.path.exists(dst_file)
st = os.stat(dst_file)
assert st.st_uid == 12345 and st.st_gid == 12346, \
f"owner not applied: uid={st.st_uid} gid={st.st_gid}"
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
def test_chown_overrides_ownership_as_root(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "identity_chown_root_source")
dest = os.path.join(TEST_DATA_DIR, "identity_chown_root_dest")
clean_dir(source)
clean_dir(dest)
src_file = os.path.join(source, "f.txt")
with open(src_file, "wb") as f:
f.write(b"root chown")
os.chown(src_file, 1, 1)
result, _ = run_client(source, dest,
flags=["-M", "--chown=@12345:@54321"],
port=shared_server.port)
assert result.returncode == 0, \
f"exit {result.returncode}: {(result.stderr or '')[:200]}"
received = get_dest_received_dir(dest, source)
dst_file = os.path.join(received, "f.txt")
assert os.path.exists(dst_file)
st = os.stat(dst_file)
assert st.st_uid == 12345 and st.st_gid == 54321, \
f"--chown not applied: uid={st.st_uid} gid={st.st_gid}"
+163
View File
@@ -7,6 +7,8 @@
#include "log.h"
#include "test_utils.h"
#include "utils.h"
#include <pwd.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -2083,8 +2085,169 @@ static void test_validate_config_append_verify_rejects_whole_file() {
EXPECT_FALSE(validate_config(cfg));
config_delete(cfg);
}
/* --numeric-ids is a plain boolean flag. */
static void test_parse_args_numeric_ids() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--numeric-ids", "/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->numeric_ids);
config_delete(cfg);
}
/* --usermap / --groupmap resolve an rsync subset into numeric FROM:TO pairs and
* imply metadata preservation (so the source uid/gid travel on the wire). */
static void test_parse_args_usermap() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--usermap=@1000:@1001", "/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->use_metadata);
EXPECT_EQ_INT(cfg->usermap_count, 1);
EXPECT_EQ_INT(cfg->usermap[0].from, 1000);
EXPECT_EQ_INT(cfg->usermap[0].to, 1001);
config_delete(cfg);
/* Space form, multiple rules, comma-separated. */
cfg = config_create();
positional_count = 0;
char* argv2[] = {"fastsync", "--usermap", "@1:@2,@3:@4", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->usermap_count, 2);
EXPECT_EQ_INT(cfg->usermap[0].from, 1);
EXPECT_EQ_INT(cfg->usermap[0].to, 2);
EXPECT_EQ_INT(cfg->usermap[1].from, 3);
EXPECT_EQ_INT(cfg->usermap[1].to, 4);
config_delete(cfg);
/* '*' FROM means match any id; '*' TO means current user. */
cfg = config_create();
positional_count = 0;
char* argv3[] = {"fastsync", "--usermap=*:@2000", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->usermap[0].from, IDENTITY_MATCH_ANY);
EXPECT_EQ_INT(cfg->usermap[0].to, 2000);
config_delete(cfg);
}
static void test_parse_args_groupmap() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--groupmap=@100:@101", "/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->use_metadata);
EXPECT_EQ_INT(cfg->groupmap_count, 1);
EXPECT_EQ_INT(cfg->groupmap[0].from, 100);
EXPECT_EQ_INT(cfg->groupmap[0].to, 101);
config_delete(cfg);
}
/* A name in a map can be resolved to a number via the local user database. */
static void test_parse_args_usermap_name_resolution() {
struct passwd* self = getpwuid(geteuid());
if (!self)
return; /* cannot construct a resolvable name deterministically */
char map_value[128];
snprintf(map_value, sizeof(map_value), "%s:@0", self->pw_name);
Config* cfg = config_create();
int positional_args[2];
int positional_count = 0;
char* argv[] = {"fastsync", (char*)"--usermap", map_value, "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->usermap_count, 1);
EXPECT_EQ_INT(cfg->usermap[0].from, (int32_t)self->pw_uid);
config_delete(cfg);
}
/* --chown parses USER:GROUP / USER / :GROUP, numeric ids, and '*'. */
static void test_parse_args_chown() {
Config* cfg = config_create();
char* argv[] = {"fastsync", "--chown=@1000:@1001", "/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->use_metadata);
EXPECT_TRUE(cfg->chown_uid_set);
EXPECT_EQ_INT(cfg->chown_uid, 1000);
EXPECT_TRUE(cfg->chown_gid_set);
EXPECT_EQ_INT(cfg->chown_gid, 1001);
config_delete(cfg);
/* --chown=:GROUP sets only the group. */
cfg = config_create();
positional_count = 0;
char* argv2[] = {"fastsync", "--chown=:@1001", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), 0);
EXPECT_FALSE(cfg->chown_uid_set);
EXPECT_TRUE(cfg->chown_gid_set);
EXPECT_EQ_INT(cfg->chown_gid, 1001);
config_delete(cfg);
/* --chown=USER sets only the owner. */
cfg = config_create();
positional_count = 0;
char* argv3[] = {"fastsync", "--chown=@1000", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->chown_uid_set);
EXPECT_EQ_INT(cfg->chown_uid, 1000);
EXPECT_FALSE(cfg->chown_gid_set);
config_delete(cfg);
/* '*' means current user/group. */
cfg = config_create();
positional_count = 0;
char* argv4[] = {"fastsync", "--chown=*:*", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv4, positional_args, &positional_count), 0);
EXPECT_TRUE(cfg->chown_uid_set);
EXPECT_EQ_INT(cfg->chown_uid, IDENTITY_CURRENT);
EXPECT_TRUE(cfg->chown_gid_set);
EXPECT_EQ_INT(cfg->chown_gid, IDENTITY_CURRENT);
config_delete(cfg);
}
/* Malformed identity specs are rejected, never silently ignored. */
static void test_parse_args_rejects_malformed_identity() {
struct {
const char* opt;
const char* val;
} bad[] = {
{"--usermap", "@1000"},
{"--usermap", ":1000"},
{"--usermap", "definitely_not_a_real_user_zzz:@1"},
{"--groupmap", "@1"},
{"--groupmap", "no_such_group_qqq:x"},
{"--chown", "a:b:c"},
{"--chown", "no_such_user_zzz:"},
};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", (char*)bad[i].opt, (char*)bad[i].val, "/src", "/dst"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
/* An option with a missing value fails at the CLI layer. */
Config* cfg = config_create();
char* argv[] = {"fastsync", "--chown"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 2, argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
void test_client_cli() {
test_validate_config_required_paths();
test_parse_args_numeric_ids();
test_parse_args_usermap();
test_parse_args_groupmap();
test_parse_args_usermap_name_resolution();
test_parse_args_chown();
test_parse_args_rejects_malformed_identity();
test_parse_args_append();
test_parse_args_append_verify();
test_parse_args_append_both();
+98
View File
@@ -908,6 +908,102 @@ static void test_config_receive_rejects_invalid_checksum_algo() {
EXPECT_FALSE(roundtrip_config_ok(c));
config_delete(c);
}
/* The identity-mapping fields (--numeric-ids / --usermap / --groupmap /
--chown) cross the config wire unchanged: the receiver needs them to apply
ownership with the same policy the client requested. */
static void test_config_identity_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->numeric_ids = true;
send_cfg->chown_uid_set = true;
send_cfg->chown_uid = 1001;
send_cfg->chown_gid_set = true;
send_cfg->chown_gid = IDENTITY_CURRENT;
send_cfg->usermap_count = 2;
send_cfg->usermap = calloc(2, sizeof(IdentityMap));
send_cfg->usermap[0].from = IDENTITY_MATCH_ANY;
send_cfg->usermap[0].to = 65534;
send_cfg->usermap[1].from = 1000;
send_cfg->usermap[1].to = 1000;
send_cfg->groupmap_count = 1;
send_cfg->groupmap = calloc(1, sizeof(IdentityMap));
send_cfg->groupmap[0].from = 0;
send_cfg->groupmap[0].to = IDENTITY_CURRENT;
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->numeric_ids && recv->chown_uid_set && recv->chown_uid == 1001 &&
recv->chown_gid_set && recv->chown_gid == IDENTITY_CURRENT && recv->usermap_count == 2 &&
recv->groupmap_count == 1 && recv->usermap[0].from == IDENTITY_MATCH_ANY &&
recv->usermap[0].to == 65534 && recv->usermap[1].from == 1000 &&
recv->usermap[1].to == 1000 && recv->groupmap[0].from == 0 &&
recv->groupmap[0].to == IDENTITY_CURRENT;
}
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);
}
}
/* The receiver must reject an out-of-range identity-map count or id on the
wire (defense against a malicious/oversized table). */
static void test_config_receive_rejects_invalid_identity() {
if (is_running_under_valgrind())
return;
Config* c = config_create();
EXPECT_NOT_NULL(c);
c->send_directory = str_dup("/src");
c->receive_root_directory = str_dup("/dst");
c->usermap_count = 1;
c->usermap = calloc(1, sizeof(IdentityMap));
c->usermap[0].from = -2; /* below IDENTITY_MATCH_ANY */
c->usermap[0].to = 0;
EXPECT_FALSE(roundtrip_config_ok(c));
config_delete(c);
c = config_create();
EXPECT_NOT_NULL(c);
c->send_directory = str_dup("/src");
c->receive_root_directory = str_dup("/dst");
c->chown_uid_set = true;
c->chown_uid = -5;
EXPECT_FALSE(roundtrip_config_ok(c));
config_delete(c);
/* A well-formed identity config still round-trips through the shared helper. */
c = config_create();
EXPECT_NOT_NULL(c);
c->send_directory = str_dup("/src");
c->receive_root_directory = str_dup("/dst");
c->numeric_ids = true;
EXPECT_TRUE(roundtrip_config_ok(c));
config_delete(c);
}
void test_config() {
test_config_lifecycle();
test_config_ssh_dest();
@@ -932,6 +1028,8 @@ void test_config() {
test_config_basis_normalization();
test_config_checksum_options_wire_roundtrip();
test_config_receive_rejects_invalid_checksum_algo();
test_config_identity_wire_roundtrip();
test_config_receive_rejects_invalid_identity();
}
test_config_delete_timing_early_helper();
test_config_is_remote_dest();