chmod: match rsync 3.4.1 --chmod and remove mode masking (#293)
- --chmod no longer implies --preserve-perms; repeated --chmod options accumulate, and D/F/X selectors plus s/t special bits are supported with rsync's exact parse_chmod/tweak_mode semantics. - Stop masking group/other write and setuid/setgid/sticky: -p copies the source mode exactly, no-p new entries use source&~umask, directories keep setgid/sticky, and special nodes follow the same rules. - Apply ownership before mode on the fd path so a chown cannot clear the setuid/setgid bits -p just restored (rsync order). - Update unit and integration tests, including differential checks against rsync 3.4.1.
This commit is contained in:
@@ -936,6 +936,133 @@ class TestChmod:
|
||||
received = get_dest_received_dir(DEST_DIR, SOURCE_DIR)
|
||||
assert (os.stat(os.path.join(received, "small.txt")).st_mode & 0o777) == 0o644
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_chmod_does_not_imply_perms(self, shared_server):
|
||||
"""rsync's --chmod only tweaks the mode used for a NEW destination; it
|
||||
does not imply -p, so a pre-existing destination keeps its own mode."""
|
||||
source = os.path.join(TEST_DATA_DIR, "chmod_nop_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "chmod_nop_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
src_file = os.path.join(source, "f.txt")
|
||||
with open(src_file, "wb") as fh:
|
||||
fh.write(b"one\n")
|
||||
os.chmod(src_file, 0o644)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"seed failed: {(result.stderr or '')[:200]}"
|
||||
dst_file = os.path.join(get_dest_received_dir(dest, source), "f.txt")
|
||||
os.chmod(dst_file, 0o600)
|
||||
with open(src_file, "wb") as fh:
|
||||
fh.write(b"two, changed content\n")
|
||||
|
||||
result, _ = run_client(source, dest, flags=["--chmod=go+w"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--chmod failed: {(result.stderr or result.stdout)[:300]}"
|
||||
got = stat.S_IMODE(os.stat(dst_file).st_mode)
|
||||
assert got == 0o600, \
|
||||
f"--chmod must not imply -p; existing dest mode changed to {oct(got)}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_chmod_go_w_with_perms(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "chmod_gow_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "chmod_gow_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
src_file = os.path.join(source, "f.txt")
|
||||
with open(src_file, "wb") as fh:
|
||||
fh.write(b"x\n")
|
||||
os.chmod(src_file, 0o644)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-p", "--chmod=go+w"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"-p --chmod=go+w failed: {(result.stderr or result.stdout)[:300]}"
|
||||
got = stat.S_IMODE(os.stat(
|
||||
os.path.join(get_dest_received_dir(dest, source), "f.txt")).st_mode)
|
||||
assert got == 0o666, f"--chmod=go+w must grant group/other write, got {oct(got)}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_chmod_repeated_options_accumulate(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "chmod_append_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "chmod_append_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
src_file = os.path.join(source, "f.txt")
|
||||
with open(src_file, "wb") as fh:
|
||||
fh.write(b"x\n")
|
||||
os.chmod(src_file, 0o644)
|
||||
|
||||
result, _ = run_client(source, dest,
|
||||
flags=["-p", "--chmod=a+r", "--chmod=a-w"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"append --chmod failed: {(result.stderr or result.stdout)[:300]}"
|
||||
got = stat.S_IMODE(os.stat(
|
||||
os.path.join(get_dest_received_dir(dest, source), "f.txt")).st_mode)
|
||||
assert got == 0o444, f"repeated --chmod must accumulate, got {oct(got)}"
|
||||
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.skipif(shutil.which("rsync") is None, reason="rsync not installed")
|
||||
def test_chmod_matches_rsync(self, shared_server):
|
||||
"""Differential --chmod verification against rsync 3.4.1 for D/F/X
|
||||
selectors, no-/with--p new files, special bits, and append semantics."""
|
||||
cases = [
|
||||
("go_w_no_p", ["--chmod=go+w"], {}, {"f.txt": (b"x", 0o644)}, ["f.txt"]),
|
||||
("go_w_p", ["-p", "--chmod=go+w"], {}, {"f.txt": (b"x", 0o644)}, ["f.txt"]),
|
||||
("world_writable_p", ["-p"], {}, {"f.txt": (b"x", 0o666)}, ["f.txt"]),
|
||||
("setgid_sticky_dirs_p", ["-p"], {"sg": 0o2755, "st": 0o1777},
|
||||
{"sg/a.txt": (b"x", 0o644), "st/b.txt": (b"x", 0o644)},
|
||||
["sg", "st"]),
|
||||
("special_file_p", ["-p"], {}, {"s": (b"x", 0o6755)}, ["s"]),
|
||||
("archive_special_file", ["-a"], {}, {"s": (b"x", 0o6755)}, ["s"]),
|
||||
("archive_setgid_dir", ["-a"], {"d": 0o2755},
|
||||
{"d/a.txt": (b"x", 0o644)}, ["d"]),
|
||||
("dfx_p", ["-p", "--chmod=Dg+s,Fo-w,+X"], {"d": 0o700},
|
||||
{"d/inner.txt": (b"x", 0o644), "f.txt": (b"x", 0o644)}, ["d", "f.txt"]),
|
||||
("x_selector_p", ["-p", "--chmod=a+X"], {"d": 0o600},
|
||||
{"d/inner.txt": (b"x", 0o644), "exe": (b"x", 0o755), "noexe": (b"x", 0o644)},
|
||||
["d", "exe", "noexe"]),
|
||||
("append_p", ["-p", "--chmod=a+r", "--chmod=a-w"], {},
|
||||
{"f.txt": (b"x", 0o644)}, ["f.txt"]),
|
||||
]
|
||||
for name, flags, dirs, files, check in cases:
|
||||
source = os.path.join(TEST_DATA_DIR, f"chmod_diff_{name}_src")
|
||||
fdest = os.path.join(TEST_DATA_DIR, f"chmod_diff_{name}_fs")
|
||||
rdest = os.path.join(TEST_DATA_DIR, f"chmod_diff_{name}_rsync")
|
||||
clean_dir(source)
|
||||
clean_dir(fdest)
|
||||
clean_dir(rdest)
|
||||
for rel, mode in dirs.items():
|
||||
path = os.path.join(source, rel)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
os.chmod(path, mode)
|
||||
for rel, (content, mode) in files.items():
|
||||
path = os.path.join(source, rel)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(content)
|
||||
os.chmod(path, mode)
|
||||
|
||||
rsync_result = subprocess.run(
|
||||
["rsync", "-r"] + flags + [source + "/", rdest + "/"],
|
||||
text=True, capture_output=True)
|
||||
assert rsync_result.returncode == 0, \
|
||||
f"rsync {name} failed: {rsync_result.stderr[:300]}"
|
||||
|
||||
result, _ = run_client(source, fdest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"FastSync {name} failed: {(result.stderr or result.stdout)[:300]}"
|
||||
|
||||
fs_root = get_dest_received_dir(fdest, source)
|
||||
for rel in check:
|
||||
rsync_mode = stat.S_IMODE(os.lstat(os.path.join(rdest, rel)).st_mode)
|
||||
fs_mode = stat.S_IMODE(os.lstat(os.path.join(fs_root, rel)).st_mode)
|
||||
assert fs_mode == rsync_mode, (
|
||||
f"{name}: mode mismatch for {rel}: "
|
||||
f"FastSync {oct(fs_mode)} != rsync {oct(rsync_mode)}")
|
||||
|
||||
|
||||
class TestPreallocate:
|
||||
"""--preallocate allocates the destination file space up front; the final
|
||||
|
||||
@@ -95,14 +95,14 @@ class TestPreservePerms:
|
||||
source = os.path.join(TEST_DATA_DIR, "perms_nop_new_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "perms_nop_new_dst")
|
||||
# 0664 has group/other bits that the umask strips, so the result is not
|
||||
# just the source mode. FastSync additionally never grants group/other
|
||||
# write from a client-supplied mode (S_IWGRP|S_IWOTH are always
|
||||
# cleared), so the expected mode masks those too.
|
||||
# just the source mode. Under strict rsync parity the source mode is
|
||||
# masked only by the umask (group/other write is no longer force-cleared
|
||||
# on top of it).
|
||||
_seed_file(source, dest, "f.txt", b"new\n", 0o664)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-t"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"-t failed: {(result.stderr or '')[:300]}"
|
||||
want = 0o664 & ~_process_umask() & ~0o022
|
||||
assert result.returncode == 0, f"-t failed: {(result.stderr or result.stdout)[:300]}"
|
||||
want = 0o664 & ~_process_umask()
|
||||
got = os.stat(_received(dest, source, "f.txt")).st_mode & 0o777
|
||||
assert got == want, \
|
||||
f"new no--p destination mode: want {oct(want)}, got {oct(got)}"
|
||||
@@ -254,15 +254,15 @@ class TestDirectoryModes:
|
||||
assert got == 0o750, f"-p must apply the source directory mode, got {oct(got)}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_p_sanitizes_directory_group_other_write(self, shared_server):
|
||||
# A 0777 source directory must never produce a group/other-writable
|
||||
# destination directory: the file-mode sanitization is applied to dirs.
|
||||
source, dest, _ = self._tree("dirmode_sanitize", 0o777, pin_mtime=False)
|
||||
def test_p_preserves_directory_group_other_write(self, shared_server):
|
||||
# Strict rsync parity: -p copies the source directory mode exactly,
|
||||
# including group/other write (the old sanitization is gone).
|
||||
source, dest, _ = self._tree("dirmode_go_write", 0o777, pin_mtime=False)
|
||||
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"-p failed: {(result.stderr or result.stdout)[:300]}"
|
||||
mode = os.stat(os.path.join(get_dest_received_dir(dest, source), "sub")).st_mode & 0o777
|
||||
assert mode & 0o022 == 0, \
|
||||
f"directory must never be group/other writable, got {oct(mode)}"
|
||||
assert mode == 0o777, \
|
||||
f"-p must preserve the source directory mode exactly, got {oct(mode)}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_omit_dir_times_suppresses_times_not_modes(self, shared_server):
|
||||
@@ -443,14 +443,13 @@ class TestPreserveFeatureMatrix:
|
||||
|
||||
|
||||
class TestSpecialNodeModes:
|
||||
"""Security: a client can never grant group/other write, including on a
|
||||
recreated special node (FIFO). The special-node creation path sanitizes
|
||||
S_IWGRP|S_IWOTH just like the regular-file and directory paths, so a source
|
||||
FIFO with mode 0777 must land as 0755 (owner/group/other read+exec from the
|
||||
source otherwise preserved). FIFOs are created unprivileged via mkfifo."""
|
||||
"""Strict rsync parity: with -p the source FIFO mode is copied exactly,
|
||||
including group/other write. Without -p the node follows the same
|
||||
source & ~umask base as any other new entry. FIFOs are created
|
||||
unprivileged via mkfifo."""
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_specials_p_sanitizes_fifo_group_other_write(self):
|
||||
def test_specials_p_preserves_fifo_mode(self):
|
||||
source = os.path.join(TEST_DATA_DIR, "specialmode_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "specialmode_dst")
|
||||
clean_dir(source)
|
||||
@@ -464,8 +463,8 @@ class TestSpecialNodeModes:
|
||||
# Production daemonizes with umask(0) (server.c) so the source mode is
|
||||
# what reaches mkfifo. The session server runs in the foreground and
|
||||
# would inherit the runner's umask, which alone would strip the write
|
||||
# bits and mask a regression in the sanitization. Start a dedicated
|
||||
# foreground server under umask(0) to exercise the real path.
|
||||
# bits and mask a regression. Start a dedicated foreground server under
|
||||
# umask(0) to exercise the real path.
|
||||
server = ServerManager()
|
||||
saved_umask = os.umask(0)
|
||||
try:
|
||||
@@ -486,7 +485,5 @@ class TestSpecialNodeModes:
|
||||
st = os.lstat(received)
|
||||
assert stat.S_ISFIFO(st.st_mode), f"received entry is not a FIFO: {oct(st.st_mode)}"
|
||||
mode = st.st_mode & 0o777
|
||||
assert mode & 0o022 == 0, \
|
||||
f"recreated FIFO must never be group/other writable, got {oct(mode)}"
|
||||
assert mode == 0o755, \
|
||||
f"-p must preserve the source FIFO mode minus group/other write (want 0o755), got {oct(mode)}"
|
||||
assert mode == 0o777, \
|
||||
f"-p must preserve the source FIFO mode exactly (want 0o777), got {oct(mode)}"
|
||||
|
||||
Reference in New Issue
Block a user