Merge feat/p4-identity-mapping: --numeric-ids / --usermap / --groupmap / --chown
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user