fix(identity): rsync ownership parity for numeric-ids, dirs, maps, fake-super (#286, #294)

- #286: --numeric-ids is a mapping modifier only; it no longer activates
  chown by itself (identity_active_enabled/owner/group predicates), and
  --fake-super stores the resolved mapping instead of real-chowning.
- #286: apply owner/group to directories via the deferred directory
  metadata path; capture+transmit+apply directory xattrs/ACLs (-aX/-aA),
  including default ACLs, in STATUS_MKDIR/STATUS_DIR_TIMES.
- #294: --usermap/--groupmap support inclusive ranges, '*', empty FROM
  (unnamed ids), and receiver-side TO name resolution; --chown mixing with
  a same-side map is rejected like rsync.
- Protocol 2.22.0 -> 2.23.0 (map wire entry gains from_hi + to_name;
  dir frames gain a bounded xattr block).
This commit is contained in:
2026-09-15 22:10:02 +02:00
parent 23552e823d
commit 6144c7fc7f
26 changed files with 1115 additions and 376 deletions
+93 -3
View File
@@ -317,7 +317,7 @@ static void test_parse_args_protocol_accept_current() {
Config* cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_equals[] = {"fastsync", "--source-dir", "/src",
"--dest-dir", "/dst", "--protocol=2.22.0"};
"--dest-dir", "/dst", "--protocol=2.23.0"};
int positional_args[2];
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
@@ -327,7 +327,7 @@ static void test_parse_args_protocol_accept_current() {
cfg = valid_client_config();
EXPECT_NOT_NULL(cfg);
char* argv_space[] = {"fastsync", "--source-dir", "/src", "--dest-dir",
"/dst", "--protocol", "2.22.0"};
"/dst", "--protocol", "2.23.0"};
positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 7, argv_space, positional_args, &positional_count), 0);
EXPECT_EQ_STR(cfg->version, PROTOCOL_VERSION);
@@ -2739,6 +2739,92 @@ static void test_parse_args_usermap_name_resolution() {
config_delete(cfg);
}
/* #294: rsync map FROM forms -- inclusive numeric ranges, '*' (any), and the
* empty token (ids with no name on the sender). A TO name is transmitted as a
* NAME for the receiver to resolve (rsync resolves TO names on the receiving
* side), not resolved against the client's database. */
static void test_parse_args_usermap_rsync_forms() {
int positional_args[2];
Config* cfg = config_create();
int positional_count = 0;
char* argv[] = {"fastsync", "--usermap=0-99:nobody", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->usermap_count, 1);
EXPECT_EQ_INT(cfg->usermap[0].from, 0);
EXPECT_EQ_INT(cfg->usermap[0].from_hi, 99);
EXPECT_TRUE(cfg->preserve_owner);
config_delete(cfg);
/* Empty FROM => IDENTITY_MATCH_UNNAMED. */
cfg = config_create();
positional_count = 0;
char* argv2[] = {"fastsync", "--usermap=:@0", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv2, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->usermap_count, 1);
EXPECT_EQ_INT(cfg->usermap[0].from, IDENTITY_MATCH_UNNAMED);
EXPECT_EQ_INT(cfg->usermap[0].from_hi, IDENTITY_MATCH_UNNAMED);
config_delete(cfg);
/* '*' FROM => IDENTITY_MATCH_ANY. */
cfg = config_create();
positional_count = 0;
char* argv3[] = {"fastsync", "--groupmap=*:@0", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv3, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->groupmap[0].from, IDENTITY_MATCH_ANY);
EXPECT_EQ_INT(cfg->groupmap[0].from_hi, IDENTITY_MATCH_ANY);
config_delete(cfg);
/* A TO name is kept as a receiver-resolved name, NOT resolved locally. */
cfg = config_create();
positional_count = 0;
char* argv4[] = {"fastsync", "--usermap=0:nobody", "/src", "/dst"};
EXPECT_EQ_INT(parse_args(cfg, 4, argv4, positional_args, &positional_count), 0);
EXPECT_EQ_INT(cfg->usermap_count, 1);
EXPECT_NOT_NULL(cfg->usermap[0].to_name);
if (cfg->usermap[0].to_name)
EXPECT_EQ_STR(cfg->usermap[0].to_name, "nobody");
config_delete(cfg);
}
/* #294: rsync refuses to mix --chown with --usermap/--groupmap on the same
* side (either order). --chown=USER conflicts with a prior --usermap;
* --chown=:GROUP conflicts with a prior --groupmap; the opposite side is fine. */
static void test_parse_args_identity_map_chown_conflict() {
int positional_args[2];
struct {
const char* a;
const char* b;
} bad[] = {
{"--usermap=0:1", "--chown=2:3"}, {"--chown=2:3", "--usermap=0:1"},
{"--chown=2", "--usermap=0:1"}, {"--chown=2:3", "--groupmap=0:1"},
{"--groupmap=0:1", "--chown=2:3"}, {"--chown=:3", "--groupmap=0:1"},
};
for (size_t i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", (char*)bad[i].a, (char*)bad[i].b, "/src", "/dst"};
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), -1);
config_delete(cfg);
}
/* The opposite-side combinations rsync allows must still parse. */
struct {
const char* a;
const char* b;
} ok[] = {
{"--chown=2", "--groupmap=0:1"},
{"--chown=:3", "--usermap=0:1"},
};
for (size_t i = 0; i < sizeof(ok) / sizeof(ok[0]); i++) {
Config* cfg = config_create();
char* argv[] = {"fastsync", (char*)ok[i].a, (char*)ok[i].b, "/src", "/dst"};
int positional_count = 0;
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
config_delete(cfg);
}
}
/* --chown parses USER:GROUP / USER / :GROUP, numeric ids, and '*'. */
static void test_parse_args_chown() {
Config* cfg = config_create();
@@ -2856,8 +2942,10 @@ static void test_parse_args_rejects_malformed_identity() {
const char* val;
} bad[] = {
{"--usermap", "@1000"},
{"--usermap", ":1000"},
{"--usermap", "definitely_not_a_real_user_zzz:@1"},
{"--usermap", "0-"},
{"--usermap", "5-2:@1"},
{"--usermap", "roo*:@1"},
{"--groupmap", "@1"},
{"--groupmap", "no_such_group_qqq:x"},
{"--chown", "a:b:c"},
@@ -3837,6 +3925,8 @@ void test_client_cli() {
test_parse_args_usermap();
test_parse_args_groupmap();
test_parse_args_usermap_name_resolution();
test_parse_args_usermap_rsync_forms();
test_parse_args_identity_map_chown_conflict();
test_parse_args_chown();
test_parse_args_copy_as();
test_parse_args_rejects_malformed_identity();