identity mapping: --numeric-ids / --usermap / --groupmap / --chown
CI / lint (pull_request) Failing after 3s
CI / build-and-test (pull_request) Skipped
CI / sanitizers (address) (pull_request) Skipped
CI / sanitizers (undefined) (pull_request) Skipped
CI / fuzz-build (pull_request) Skipped
CI / coverage (pull_request) Skipped
CI / valgrind (pull_request) Skipped

Receiver-side ownership application, opt-in and privilege-gated:
- OFF for every existing transfer (plain -M/--preserve still never applies
  ownership); only triggers on an explicit identity flag + receiver permission.
- EPERM/EACCES warn-and-continue (never aborts); other fchown errors escalate.
- fd-relative fchown after the file is written (symlink-safe, confined).
- New src/shared/identity.{c,h}; config fields numeric_ids / chown uid/gid /
  usermap + groupmap id-pair tables cross the wire; PROTOCOL_VERSION 2.10.0
  -> 2.11.0. CLI in client_cli.c; per-connection snapshot in server.c.
- Review fixes: EPERM/EACCES-only warn-and-continue, prominent root-receiver
  notice, identity_clear_active on early server error paths, --numeric-ids
  kept inert standalone (removed from activation trigger set).
This commit is contained in:
2026-09-08 18:23:43 +02:00
parent 829e760086
commit 53ce00b830
12 changed files with 1094 additions and 8 deletions
+86
View File
@@ -3676,3 +3676,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}"