- #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:
@@ -36,7 +36,7 @@ from common import ( # noqa: E402
|
||||
verify_transfer,
|
||||
)
|
||||
|
||||
PROTOCOL_VERSION = b"2.22.0"
|
||||
PROTOCOL_VERSION = b"2.23.0"
|
||||
STATUS_MANIFEST = 5
|
||||
STATUS_OK = 0
|
||||
|
||||
|
||||
@@ -4510,9 +4510,11 @@ class TestIdentityMapping:
|
||||
clean_dir(dest)
|
||||
with open(os.path.join(source, "f.txt"), "wb") as f:
|
||||
f.write(b"mapped")
|
||||
# #294: --chown cannot be mixed with --usermap/--groupmap on the same
|
||||
# side, so the maps travel together and --chown is exercised separately.
|
||||
result, _ = run_client(
|
||||
source, dest,
|
||||
flags=["--preserve", "--usermap=@1000:@1001", "--groupmap=@100:@101", "--chown=@2000:@2001"],
|
||||
flags=["--preserve", "--usermap=@1000:@1001", "--groupmap=@100:@101"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"exit {result.returncode}: {(result.stderr or '')[:200]}"
|
||||
@@ -4520,8 +4522,14 @@ class TestIdentityMapping:
|
||||
with open(os.path.join(received, "f.txt"), "rb") as f:
|
||||
assert f.read() == b"mapped"
|
||||
|
||||
result, _ = run_client(source, dest, flags=["--preserve", "--chown=@2000:@2001"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"chown exit {result.returncode}: {(result.stderr or '')[:200]}"
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||
def test_numeric_ids_applies_ownership_as_root(self, shared_server):
|
||||
def test_numeric_ids_alone_does_not_apply_ownership_as_root(self, shared_server):
|
||||
# #286.1: --numeric-ids is a mapping modifier, not an ownership request.
|
||||
source = os.path.join(TEST_DATA_DIR, "identity_root_source")
|
||||
dest = os.path.join(TEST_DATA_DIR, "identity_root_dest")
|
||||
clean_dir(source)
|
||||
@@ -4539,8 +4547,8 @@ class TestIdentityMapping:
|
||||
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}"
|
||||
assert st.st_uid != 12345, \
|
||||
f"--numeric-ids alone must not chown: 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):
|
||||
@@ -4627,21 +4635,21 @@ class TestSuperPrivilege:
|
||||
f"--super alone must not apply ownership (uid={st.st_uid} gid={st.st_gid})"
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||
def test_super_with_numeric_ids_applies_ownership_as_root(self, shared_server):
|
||||
"""Control: an explicit identity policy is what enables ownership, so
|
||||
--numeric-ids --super still applies the raw ids as root (the very
|
||||
ownership --no-super suppresses)."""
|
||||
def test_super_with_owner_numeric_ids_applies_ownership_as_root(self, shared_server):
|
||||
"""Control: an explicit ownership request is what enables ownership, so
|
||||
-a --numeric-ids --super applies the raw ids as root (the very ownership
|
||||
--no-super suppresses). --numeric-ids itself is only the modifier."""
|
||||
source, dest = self._seed("supernumeric")
|
||||
os.chown(os.path.join(source, "f.txt"), 12345, 12346)
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--preserve", "--numeric-ids", "--super"],
|
||||
flags=["-a", "--numeric-ids", "--super"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"exit {result.returncode}: {(result.stderr or '')[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
st = os.stat(os.path.join(received, "f.txt"))
|
||||
assert (st.st_uid, st.st_gid) == (12345, 12346), \
|
||||
f"--numeric-ids --super should apply raw ids: uid={st.st_uid} gid={st.st_gid}"
|
||||
f"-a --numeric-ids --super should apply raw ids: uid={st.st_uid} gid={st.st_gid}"
|
||||
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||
@@ -5453,6 +5461,79 @@ class TestExtendedAttributes:
|
||||
assert len(fields) == 5
|
||||
assert fields[0] == str(uid), f"reserved uid field {fields[0]} != source uid {uid}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_fake_super_records_resolved_chown_without_real_chown(self, shared_server):
|
||||
"""#294: --fake-super must NOT real-chown the recorded owner; it records
|
||||
the RESOLVED ownership (here a --chown mapping) in the reserved xattr."""
|
||||
source, dest = self._source_and_dest("fakesuper_chown")
|
||||
f = os.path.join(source, "data.txt")
|
||||
with open(f, "wb") as fh:
|
||||
fh.write(b"fake-super chown\n")
|
||||
if not _xattr_supported(f):
|
||||
pytest.skip("filesystem does not support xattrs")
|
||||
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["--fake-super", "--chown=@33333:@44444"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--fake-super --chown sync failed: {(result.stderr or result.stdout)[:300]}"
|
||||
dst = os.path.join(get_dest_received_dir(dest, source), "data.txt")
|
||||
record = os.getxattr(dst, "user.fastsync.stat").decode().split(":")
|
||||
assert record[0] == "33333", f"recorded owner {record[0]} != resolved 33333"
|
||||
assert record[1] == "44444", f"recorded group {record[1]} != resolved 44444"
|
||||
st = os.stat(dst)
|
||||
assert st.st_uid != 33333, "--fake-super must not real-chown the recorded owner"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_directory_xattrs_preserved(self, shared_server):
|
||||
"""#286.3: -aX must preserve user.* xattrs on DIRECTORIES, not just files."""
|
||||
source, dest = self._source_and_dest("dirxattr")
|
||||
os.makedirs(os.path.join(source, "sub"))
|
||||
if not _xattr_supported(source):
|
||||
pytest.skip("filesystem does not support user xattrs")
|
||||
os.setxattr(source, "user.rootdir", b"r")
|
||||
os.setxattr(os.path.join(source, "sub"), "user.subdir", b"s")
|
||||
with open(os.path.join(source, "sub", "f.txt"), "wb") as fh:
|
||||
fh.write(b"x\n")
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-aX"], port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"-aX dir sync failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert os.getxattr(received, "user.rootdir") == b"r"
|
||||
assert os.getxattr(os.path.join(received, "sub"), "user.subdir") == b"s"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_directory_default_acl_preserved(self, shared_server):
|
||||
"""#286.3: -aA must preserve a directory's default POSIX ACL (the
|
||||
system.posix_acl_default xattr), which regular-file ACLs do not cover."""
|
||||
source, dest = self._source_and_dest("diracl")
|
||||
sub = os.path.join(source, "sub")
|
||||
os.makedirs(sub)
|
||||
# A child is needed because FastSync deliberately does not materialize
|
||||
# empty directories; the implicit parent is created by the child write.
|
||||
with open(os.path.join(sub, "f.txt"), "wb") as fh:
|
||||
fh.write(b"acl dir\n")
|
||||
if not _xattr_supported(sub):
|
||||
pytest.skip("filesystem does not support xattrs")
|
||||
if shutil.which("setfacl") is None:
|
||||
pytest.skip("setfacl is not available")
|
||||
acl = subprocess.run(["setfacl", "-m", "d:u::rwx,d:g::rx,d:o::---", sub],
|
||||
capture_output=True, text=True)
|
||||
if acl.returncode != 0:
|
||||
pytest.skip(f"cannot set a default ACL: {acl.stderr.strip()}")
|
||||
try:
|
||||
before = os.getxattr(sub, "system.posix_acl_default")
|
||||
except OSError as e:
|
||||
pytest.skip(f"no default ACL xattr: {e}")
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-aA"], port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"-aA dir sync failed: {(result.stderr or result.stdout)[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
assert os.getxattr(os.path.join(received, "sub"),
|
||||
"system.posix_acl_default") == before
|
||||
|
||||
|
||||
class TestConnectivityClientOptions:
|
||||
"""Phase 5 connectivity launch options (--outbuf, --blocking-io).
|
||||
@@ -5878,8 +5959,8 @@ class TestCopyAs:
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.skipif(os.geteuid() != 0, reason="requires a root receiver to chown")
|
||||
def test_root_copy_as_with_fake_super_keeps_target_owner(self, shared_server):
|
||||
"""--fake-super must not let the recorded source owner override the
|
||||
--copy-as forced owner (copy-as is authoritative)."""
|
||||
"""#294: --fake-super records the RESOLVED copy-as ownership without
|
||||
real-chowning; the recorded source owner can never override copy-as."""
|
||||
source = os.path.join(TEST_DATA_DIR, "copyas_fakesuper_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "copyas_fakesuper_dst")
|
||||
clean_dir(source)
|
||||
@@ -5887,6 +5968,8 @@ class TestCopyAs:
|
||||
src_file = os.path.join(source, "mixed.txt")
|
||||
with open(src_file, "wb") as fh:
|
||||
fh.write(b"copy-as wins over fake-super\n")
|
||||
if not _xattr_supported(src_file):
|
||||
pytest.skip("filesystem does not support user xattrs")
|
||||
os.chown(src_file, 12345, 12346)
|
||||
|
||||
result, _ = run_client(source, dest,
|
||||
@@ -5897,7 +5980,13 @@ class TestCopyAs:
|
||||
f"{(result.stderr or result.stdout)[:400]}"
|
||||
)
|
||||
received = get_dest_received_dir(dest, source)
|
||||
st = os.lstat(os.path.join(received, "mixed.txt"))
|
||||
assert (st.st_uid, st.st_gid) == (65534, 65534), (
|
||||
f"--fake-super overrode --copy-as: uid={st.st_uid} gid={st.st_gid}"
|
||||
dst = os.path.join(received, "mixed.txt")
|
||||
record = os.getxattr(dst, "user.fastsync.stat").decode().split(":")
|
||||
assert (record[0], record[1]) == ("65534", "65534"), (
|
||||
f"fake-super must record the resolved copy-as ownership: {record[:2]}"
|
||||
)
|
||||
st = os.lstat(dst)
|
||||
assert (st.st_uid, st.st_gid) != (12345, 12346), (
|
||||
f"--fake-super must not real-chown the recorded source owner: "
|
||||
f"uid={st.st_uid} gid={st.st_gid}"
|
||||
)
|
||||
|
||||
@@ -94,14 +94,14 @@ def _seed_protocol_source(source):
|
||||
class TestProtocol:
|
||||
@pytest.mark.ci
|
||||
def test_protocol_current_version_accepted(self, shared_server):
|
||||
"""--protocol=2.22.0 (the current PROTOCOL_VERSION) is accepted and the
|
||||
"""--protocol=2.23.0 (the current PROTOCOL_VERSION) is accepted and the
|
||||
transfer completes normally."""
|
||||
source = os.path.join(TEST_DATA_DIR, "proto_ok_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "proto_ok_dst")
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
os.makedirs(dest)
|
||||
_seed_protocol_source(source)
|
||||
result, _ = run_client(source, dest, flags=["--protocol=2.22.0"],
|
||||
result, _ = run_client(source, dest, flags=["--protocol=2.23.0"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
|
||||
|
||||
@@ -340,16 +340,85 @@ class TestOwnershipRoot:
|
||||
assert (st.st_uid, st.st_gid) == (33333, 44444), \
|
||||
f"--chown must override -o, got uid={st.st_uid} gid={st.st_gid}"
|
||||
|
||||
def test_fake_super_o_does_not_change_group(self, shared_server):
|
||||
# --fake-super replays the recorded source stat; with only -o requested
|
||||
# it must apply the owner but leave the group untouched (MAJOR 1).
|
||||
def test_fake_super_o_does_not_real_chown(self, shared_server):
|
||||
# #294: --fake-super only RECORDS ownership; it must never real-chown the
|
||||
# recorded source owner (that defeats the point of the flag). With -o the
|
||||
# resolved owner is parked in the reserved xattr and the on-disk owner is
|
||||
# left as the receiver's.
|
||||
source, dest = self._seed_owned("fake_o", 12345, 54321)
|
||||
result, _ = run_client(source, dest, flags=["--fake-super", "-o"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, f"--fake-super -o failed: {(result.stderr or '')[:300]}"
|
||||
dst = _received(dest, source, "f.txt")
|
||||
st = os.stat(dst)
|
||||
assert st.st_uid != 12345, \
|
||||
f"--fake-super -o must NOT real-chown the source owner, got uid={st.st_uid}"
|
||||
record = os.getxattr(dst, "user.fastsync.stat").decode()
|
||||
fields = record.split(":")
|
||||
assert fields[0] == "12345", \
|
||||
f"--fake-super must record the resolved owner, got {fields[0]}"
|
||||
|
||||
def test_o_applies_directory_owner(self, shared_server):
|
||||
"""#286.2: -o must apply the source owner to DIRECTORIES too (the
|
||||
deferred directory-metadata application now runs the identity path)."""
|
||||
source = os.path.join(TEST_DATA_DIR, "root_dir_o_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "root_dir_o_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
os.makedirs(os.path.join(source, "sub", "deep"))
|
||||
with open(os.path.join(source, "sub", "deep", "f.txt"), "wb") as fh:
|
||||
fh.write(b"dir owner\n")
|
||||
os.chown(os.path.join(source, "sub"), 12345, 12346)
|
||||
os.chown(os.path.join(source, "sub", "deep"), 23456, 34567)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-o", "-t"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"-o dir failed: {(result.stderr or '')[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
sub = os.stat(os.path.join(received, "sub"))
|
||||
deep = os.stat(os.path.join(received, "sub", "deep"))
|
||||
assert sub.st_uid == 12345, f"dir 'sub' owner not applied: {sub.st_uid}"
|
||||
assert deep.st_uid == 23456, f"dir 'sub/deep' owner not applied: {deep.st_uid}"
|
||||
# -o alone must not change the group.
|
||||
assert sub.st_gid != 12346
|
||||
|
||||
def test_a_applies_directory_owner_and_group(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "root_dir_a_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "root_dir_a_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
os.makedirs(os.path.join(source, "sub"))
|
||||
with open(os.path.join(source, "sub", "f.txt"), "wb") as fh:
|
||||
fh.write(b"dir owner group\n")
|
||||
os.chown(os.path.join(source, "sub"), 12345, 54321)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-a"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"-a dir failed: {(result.stderr or '')[:300]}"
|
||||
received = get_dest_received_dir(dest, source)
|
||||
st = os.stat(os.path.join(received, "sub"))
|
||||
assert (st.st_uid, st.st_gid) == (12345, 54321), \
|
||||
f"-a must apply dir owner+group, got uid={st.st_uid} gid={st.st_gid}"
|
||||
|
||||
def test_numeric_ids_alone_does_not_chown(self, shared_server):
|
||||
"""#286.1: --numeric-ids is a mapping modifier, not an ownership request.
|
||||
`-t --numeric-ids` must leave the receiver's ownership untouched."""
|
||||
source, dest = self._seed_owned("num_only", 12345, 54321)
|
||||
result, _ = run_client(source, dest, flags=["-t", "--numeric-ids"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"-t --numeric-ids failed: {(result.stderr or '')[:300]}"
|
||||
st = os.stat(_received(dest, source, "f.txt"))
|
||||
assert st.st_uid == 12345, f"--fake-super -o must apply the owner, got uid={st.st_uid}"
|
||||
assert st.st_gid != 54321, "--fake-super -o must not change the group"
|
||||
assert st.st_uid != 12345, \
|
||||
f"--numeric-ids alone must not chown, got uid={st.st_uid}"
|
||||
|
||||
def test_numeric_ids_with_o_uses_raw_id(self, shared_server):
|
||||
source, dest = self._seed_owned("num_o", 12345, 54321)
|
||||
result, _ = run_client(source, dest, flags=["-o", "-t", "--numeric-ids"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"-o --numeric-ids failed: {(result.stderr or '')[:300]}"
|
||||
st = os.stat(_received(dest, source, "f.txt"))
|
||||
assert st.st_uid == 12345, \
|
||||
f"-o --numeric-ids must apply the raw id, got uid={st.st_uid}"
|
||||
|
||||
|
||||
class TestPreserveFeatureMatrix:
|
||||
|
||||
Reference in New Issue
Block a user