feat: per-attribute preservation flags -p/-t/-o/-g with --no-* negations (protocol 2.22.0)
Split FastSync's single use_metadata bundle into four independent rsync-parity attributes: preserve_perms, preserve_times, preserve_owner, preserve_group. use_metadata is now a derived transport bit (config_derived_use_metadata). CLI: real -p/--perms, -t/--times, -o/--owner, -g/--group plus --no-perms/--no-times/--no-owner/--no-group (short and long) and --no-preserve; -a is now rsync -rlptgoD; --preserve = -pt; -A implies -p; -X does not; --chmod implies -p; --usermap/--groupmap/--chown imply owner/group per side; --incremental/--delta still auto-preserve unless negated. Receiver: per-attribute FileAttrPolicy gating for files, dirs (modes applied at end of transfer), symlinks and specials; rsync -E read-bit rule; new files get source_mode & ~umask sanitized (no group/other write); per-side identity resolution; deferred directory metadata; batch dir-metadata replay; daemon modules without 'client owner = yes' no longer refuse plain -a but force super off (no ownership) with a warning. Wire: PROTOCOL_VERSION 2.21.0 -> 2.22.0 (four appended config bools, golden 653 / 95530566005420798). FileMetadata/chunk/batch framing unchanged. Docs/CHANGELOG/CMake updated to 2.22.0.
This commit is contained in:
@@ -36,7 +36,7 @@ from common import ( # noqa: E402
|
||||
verify_transfer,
|
||||
)
|
||||
|
||||
PROTOCOL_VERSION = b"2.21.0"
|
||||
PROTOCOL_VERSION = b"2.22.0"
|
||||
STATUS_MANIFEST = 5
|
||||
STATUS_OK = 0
|
||||
|
||||
|
||||
@@ -881,9 +881,16 @@ class TestExecutability:
|
||||
assert result.returncode == 0, f"Executability sync failed: {result.stderr[:200]}"
|
||||
received_file = os.path.join(get_dest_received_dir(dest, source), "tool.sh")
|
||||
received_mode = os.stat(received_file).st_mode
|
||||
assert received_mode & 0o111 == 0o111
|
||||
assert received_mode & 0o600 == 0o600
|
||||
assert received_mode & 0o077 == 0o011
|
||||
# rsync -E on a fresh destination: the base is source & ~umask, then the
|
||||
# execute bits are derived from that base's read bits. For a source of
|
||||
# 0751 this is exactly source & ~umask (owner rwx, group r-x, other --x
|
||||
# under the usual 022 umask => group/other bits 0o051, not 0o011).
|
||||
current_umask = os.umask(0)
|
||||
os.umask(current_umask)
|
||||
expected_mode = 0o751 & ~current_umask
|
||||
assert received_mode & 0o777 == expected_mode, (
|
||||
f"expected mode {oct(expected_mode)}, got {oct(received_mode & 0o777)}"
|
||||
)
|
||||
|
||||
|
||||
class TestChmod:
|
||||
|
||||
@@ -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.21.0 (the current PROTOCOL_VERSION) is accepted and the
|
||||
"""--protocol=2.22.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.21.0"],
|
||||
result, _ = run_client(source, dest, flags=["--protocol=2.22.0"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"--protocol current run failed: {(result.stderr or result.stdout)[:400]}"
|
||||
@@ -118,7 +118,8 @@ class TestProtocol:
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
os.makedirs(dest)
|
||||
_seed_protocol_source(source)
|
||||
for bad in ("2.20.0", "2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216", "31"):
|
||||
for bad in ("2.21.0", "2.20.0", "2.19.0", "2.18.0", "2.17.0", "2.15.0", "2.16.0", "216",
|
||||
"31"):
|
||||
result, _ = run_client(source, dest, flags=[f"--protocol={bad}"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode != 0, f"--protocol={bad} should be rejected"
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Wave 2b: per-attribute preservation split (-p/-t/-o/-g and their negations).
|
||||
|
||||
The receiver applies each attribute independently (see src/shared/file_attr.h).
|
||||
These tests cover the per-flag behavior end-to-end, the CLI negations, directory
|
||||
modes, and the unprivileged best-effort / root-only ownership paths. They reuse
|
||||
the established helpers from common.py.
|
||||
|
||||
The `-s` spelling is rsync's --secluded-args no-op in FastSync; chunk
|
||||
serialization is the long-form --chunk-serialization, which is what the feature
|
||||
matrix below exercises.
|
||||
"""
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from common import (
|
||||
TEST_DATA_DIR,
|
||||
run_client,
|
||||
clean_dir,
|
||||
get_dest_received_dir,
|
||||
ServerManager,
|
||||
)
|
||||
|
||||
DISTINCT_MTIME = 1_000_000_000 # 2001-09-09T01:46:40Z, a whole second
|
||||
|
||||
|
||||
def _process_umask():
|
||||
current = os.umask(0)
|
||||
os.umask(current)
|
||||
return current
|
||||
|
||||
|
||||
def _seed_file(source, dest, name, content, mode, mtime=None):
|
||||
"""Create a one-file source tree at an explicit mode (and mtime), and a
|
||||
clean destination. Returns the source file path."""
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
path = os.path.join(source, name)
|
||||
with open(path, "wb") as fh:
|
||||
fh.write(content)
|
||||
os.chmod(path, mode)
|
||||
if mtime is not None:
|
||||
os.utime(path, (mtime, mtime))
|
||||
return path
|
||||
|
||||
|
||||
def _received(dest, source, name):
|
||||
return os.path.join(get_dest_received_dir(dest, source), name)
|
||||
|
||||
|
||||
class TestPreservePerms:
|
||||
@pytest.mark.ci
|
||||
def test_p_applies_source_mode(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "perms_p_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "perms_p_dst")
|
||||
_seed_file(source, dest, "f.txt", b"perms\n", 0o750)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"-p failed: {(result.stderr or result.stdout)[:300]}"
|
||||
got = os.stat(_received(dest, source, "f.txt")).st_mode & 0o777
|
||||
assert got == 0o750, f"-p must apply the source mode, got {oct(got)}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_without_p_preexisting_dest_keeps_mode(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "perms_nop_exist_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "perms_nop_exist_dst")
|
||||
src_file = _seed_file(source, dest, "f.txt", b"one\n", 0o750)
|
||||
|
||||
# Seed the destination.
|
||||
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"seed failed: {(result.stderr or '')[:200]}"
|
||||
|
||||
# Give the destination a distinguishable mode, then re-transfer without
|
||||
# -p (but with -t so metadata still travels).
|
||||
dst_file = _received(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=["-t"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"re-run failed: {(result.stderr or '')[:200]}"
|
||||
got = os.stat(dst_file).st_mode & 0o777
|
||||
assert got == 0o600, \
|
||||
f"without -p a pre-existing destination must keep its mode, got {oct(got)}"
|
||||
with open(dst_file, "rb") as fh:
|
||||
assert fh.read() == b"two, changed content\n"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_without_p_new_dest_gets_source_and_umask(self, shared_server):
|
||||
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.
|
||||
_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()
|
||||
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)}"
|
||||
|
||||
|
||||
class TestPreserveTimes:
|
||||
@pytest.mark.ci
|
||||
def test_t_applies_mtime(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "times_t_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "times_t_dst")
|
||||
_seed_file(source, dest, "f.txt", b"times\n", 0o644, mtime=DISTINCT_MTIME)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-t"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"-t failed: {(result.stderr or '')[:300]}"
|
||||
dst_m = os.stat(_received(dest, source, "f.txt")).st_mtime
|
||||
assert abs(dst_m - DISTINCT_MTIME) < 2, \
|
||||
f"-t must apply the source mtime, got {dst_m}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_without_t_dest_mtime_differs(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "times_not_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "times_not_dst")
|
||||
_seed_file(source, dest, "f.txt", b"times\n", 0o644, mtime=DISTINCT_MTIME)
|
||||
|
||||
# -p transmits metadata but must not apply the source mtime.
|
||||
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"-p failed: {(result.stderr or '')[:300]}"
|
||||
dst_m = os.stat(_received(dest, source, "f.txt")).st_mtime
|
||||
assert abs(dst_m - DISTINCT_MTIME) > 24 * 3600, \
|
||||
f"without -t the destination mtime must not be the source mtime ({dst_m})"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_incremental_t_retransfers_after_no_t(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "times_incr_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "times_incr_dst")
|
||||
_seed_file(source, dest, "f.txt", b"retransfer\n", 0o644, mtime=DISTINCT_MTIME)
|
||||
|
||||
# First run without -t: the destination mtime becomes "now", differing
|
||||
# from the pinned source mtime.
|
||||
result, _ = run_client(source, dest, flags=["-p"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"seed failed: {(result.stderr or '')[:200]}"
|
||||
dst_file = _received(dest, source, "f.txt")
|
||||
assert abs(os.stat(dst_file).st_mtime - DISTINCT_MTIME) > 24 * 3600
|
||||
|
||||
# The incremental quick-check now sees a mtime mismatch, so the file is
|
||||
# re-transferred and -t stamps the source time.
|
||||
result, _ = run_client(source, dest, flags=["--incremental", "-t"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, f"incremental -t failed: {(result.stderr or '')[:300]}"
|
||||
dst_m = os.stat(dst_file).st_mtime
|
||||
assert abs(dst_m - DISTINCT_MTIME) < 2, \
|
||||
f"second --incremental -t run must re-transfer and stamp the mtime, got {dst_m}"
|
||||
|
||||
|
||||
class TestPreserveNegations:
|
||||
@pytest.mark.ci
|
||||
def test_a_no_owner_no_group_keeps_perms_and_times(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "neg_owner_group_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "neg_owner_group_dst")
|
||||
_seed_file(source, dest, "f.txt", b"neg\n", 0o750, mtime=DISTINCT_MTIME)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-a", "--no-owner", "--no-group"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, f"-a --no-owner --no-group: {(result.stderr or '')[:300]}"
|
||||
st = os.stat(_received(dest, source, "f.txt"))
|
||||
assert st.st_mode & 0o777 == 0o750, "perms must survive the owner/group negation"
|
||||
assert abs(st.st_mtime - DISTINCT_MTIME) < 2, "times must survive the owner/group negation"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_a_no_perms_keeps_times_and_dest_mode(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "neg_perms_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "neg_perms_dst")
|
||||
src_file = _seed_file(source, dest, "f.txt", b"one\n", 0o750, mtime=DISTINCT_MTIME)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-a"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"seed failed: {(result.stderr or '')[:200]}"
|
||||
dst_file = _received(dest, source, "f.txt")
|
||||
os.chmod(dst_file, 0o600)
|
||||
with open(src_file, "wb") as fh:
|
||||
fh.write(b"changed\n")
|
||||
# Rewriting the source bumped its mtime; restore the pinned value so the
|
||||
# --no-perms run still has a distinct source time to apply.
|
||||
os.utime(src_file, (DISTINCT_MTIME, DISTINCT_MTIME))
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-a", "--no-perms"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, f"-a --no-perms: {(result.stderr or '')[:300]}"
|
||||
st = os.stat(dst_file)
|
||||
assert st.st_mode & 0o777 == 0o600, \
|
||||
f"--no-perms must keep the destination mode, got {oct(st.st_mode & 0o777)}"
|
||||
assert abs(st.st_mtime - DISTINCT_MTIME) < 2, "--no-perms must not disable times"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_a_no_times_keeps_perms_but_not_mtime(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "neg_times_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "neg_times_dst")
|
||||
_seed_file(source, dest, "f.txt", b"neg times\n", 0o750, mtime=DISTINCT_MTIME)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-a", "--no-times"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, f"-a --no-times: {(result.stderr or '')[:300]}"
|
||||
st = os.stat(_received(dest, source, "f.txt"))
|
||||
assert st.st_mode & 0o777 == 0o750, "--no-times must not disable perms"
|
||||
assert abs(st.st_mtime - DISTINCT_MTIME) > 24 * 3600, \
|
||||
"--no-times must not apply the source mtime"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_preserve_no_preserve_clears_all(self, shared_server):
|
||||
source = os.path.join(TEST_DATA_DIR, "neg_bundle_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "neg_bundle_dst")
|
||||
_seed_file(source, dest, "f.txt", b"bundle\n", 0o750, mtime=DISTINCT_MTIME)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["--preserve", "--no-preserve"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, f"--preserve --no-preserve: {(result.stderr or '')[:300]}"
|
||||
dst_file = _received(dest, source, "f.txt")
|
||||
with open(dst_file, "rb") as fh:
|
||||
assert fh.read() == b"bundle\n"
|
||||
st = os.stat(dst_file)
|
||||
# No metadata travels at all: a new file gets the fixed safe 0644 and
|
||||
# the source mtime is not applied.
|
||||
assert st.st_mode & 0o777 == 0o644, \
|
||||
f"--no-preserve must not apply the source mode, got {oct(st.st_mode & 0o777)}"
|
||||
assert abs(st.st_mtime - DISTINCT_MTIME) > 24 * 3600, \
|
||||
"--no-preserve must not apply the source mtime"
|
||||
|
||||
|
||||
class TestDirectoryModes:
|
||||
def _tree(self, name, dir_mode, pin_mtime):
|
||||
source = os.path.join(TEST_DATA_DIR, name + "_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, name + "_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
sub = os.path.join(source, "sub")
|
||||
os.makedirs(sub)
|
||||
with open(os.path.join(sub, "file.txt"), "wb") as fh:
|
||||
fh.write(b"dir mode content\n")
|
||||
os.chmod(sub, dir_mode)
|
||||
if pin_mtime:
|
||||
os.utime(sub, (DISTINCT_MTIME, DISTINCT_MTIME))
|
||||
return source, dest, sub
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_p_applies_directory_mode(self, shared_server):
|
||||
source, dest, _ = self._tree("dirmode_p", 0o750, 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]}"
|
||||
got = os.stat(os.path.join(get_dest_received_dir(dest, source), "sub")).st_mode & 0o777
|
||||
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)
|
||||
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)}"
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_omit_dir_times_suppresses_times_not_modes(self, shared_server):
|
||||
source, dest, _ = self._tree("dirmode_omit", 0o750, pin_mtime=True)
|
||||
result, _ = run_client(source, dest, flags=["-a", "-O"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"-a -O failed: {(result.stderr or result.stdout)[:300]}"
|
||||
st = os.stat(os.path.join(get_dest_received_dir(dest, source), "sub"))
|
||||
assert st.st_mode & 0o777 == 0o750, \
|
||||
f"-O must suppress only dir times, not dir modes (got {oct(st.st_mode & 0o777)})"
|
||||
assert abs(st.st_mtime - DISTINCT_MTIME) > 5, \
|
||||
f"-O must not apply the directory mtime (got {st.st_mtime})"
|
||||
|
||||
|
||||
class TestOwnershipBestEffort:
|
||||
"""-o/-g/-a must succeed with correct content even when the receiver cannot
|
||||
chown (the unprivileged CI case). Ownership is deliberately not asserted."""
|
||||
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("flags", [["-o"], ["-g"], ["-a"]])
|
||||
def test_ownership_flags_succeed_unprivileged(self, shared_server, flags):
|
||||
tag = flags[0].strip("-")
|
||||
source = os.path.join(TEST_DATA_DIR, f"best_effort_{tag}_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"best_effort_{tag}_dst")
|
||||
_seed_file(source, dest, "f.txt", b"best effort ownership\n", 0o640)
|
||||
|
||||
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"{flags} exit {result.returncode}: {(result.stderr or result.stdout)[:300]}"
|
||||
with open(_received(dest, source, "f.txt"), "rb") as fh:
|
||||
assert fh.read() == b"best effort ownership\n"
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() != 0, reason="only root can change ownership")
|
||||
class TestOwnershipRoot:
|
||||
"""Root-only per-attribute ownership application. Not marked ci: the PR
|
||||
gate runs as an unprivileged user."""
|
||||
|
||||
def _seed_owned(self, tag, uid, gid):
|
||||
source = os.path.join(TEST_DATA_DIR, f"root_owner_{tag}_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"root_owner_{tag}_dst")
|
||||
path = _seed_file(source, dest, "f.txt", b"root ownership\n", 0o644)
|
||||
os.chown(path, uid, gid)
|
||||
return source, dest
|
||||
|
||||
def test_o_applies_owner_only(self, shared_server):
|
||||
source, dest = self._seed_owned("o", 12345, 12346)
|
||||
result, _ = run_client(source, dest, flags=["-o"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"-o failed: {(result.stderr or '')[:300]}"
|
||||
st = os.stat(_received(dest, source, "f.txt"))
|
||||
assert st.st_uid == 12345, f"-o must apply the owner, got uid={st.st_uid}"
|
||||
assert st.st_gid != 12346, "-o must not change the group"
|
||||
|
||||
def test_g_applies_group_only(self, shared_server):
|
||||
source, dest = self._seed_owned("g", 12345, 54321)
|
||||
result, _ = run_client(source, dest, flags=["-g"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"-g failed: {(result.stderr or '')[:300]}"
|
||||
st = os.stat(_received(dest, source, "f.txt"))
|
||||
assert st.st_gid == 54321, f"-g must apply the group, got gid={st.st_gid}"
|
||||
assert st.st_uid != 12345, "-g must not change the owner"
|
||||
|
||||
def test_a_applies_owner_and_group(self, shared_server):
|
||||
source, dest = self._seed_owned("a", 12345, 54321)
|
||||
result, _ = run_client(source, dest, flags=["-a"], port=shared_server.port)
|
||||
assert result.returncode == 0, f"-a failed: {(result.stderr or '')[:300]}"
|
||||
st = os.stat(_received(dest, source, "f.txt"))
|
||||
assert (st.st_uid, st.st_gid) == (12345, 54321), \
|
||||
f"-a must apply owner+group, got uid={st.st_uid} gid={st.st_gid}"
|
||||
|
||||
def test_chown_overrides_o(self, shared_server):
|
||||
source, dest = self._seed_owned("chown", 11111, 22222)
|
||||
result, _ = run_client(source, dest, flags=["-o", "--chown=@33333:@44444"],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, f"-o --chown failed: {(result.stderr or '')[:300]}"
|
||||
st = os.stat(_received(dest, source, "f.txt"))
|
||||
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).
|
||||
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]}"
|
||||
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"
|
||||
|
||||
|
||||
class TestPreserveFeatureMatrix:
|
||||
"""A representative per-attribute check under the alternate transfer engines
|
||||
(chunk serialization, --delay-updates, and the multithreaded scanner)."""
|
||||
|
||||
@pytest.mark.ci
|
||||
@pytest.mark.parametrize("extra", ["--chunk-serialization", "--delay-updates", "--threads"])
|
||||
def test_p_and_t_hold_under_engine(self, shared_server, extra):
|
||||
tag = extra.strip("-").replace("-", "_")
|
||||
source = os.path.join(TEST_DATA_DIR, f"matrix_{tag}_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, f"matrix_{tag}_dst")
|
||||
_seed_file(source, dest, "f.txt", b"matrix\n", 0o750, mtime=DISTINCT_MTIME)
|
||||
|
||||
result, _ = run_client(source, dest, flags=["-p", "-t", extra],
|
||||
port=shared_server.port)
|
||||
assert result.returncode == 0, \
|
||||
f"-p -t {extra} failed: {(result.stderr or result.stdout)[:300]}"
|
||||
st = os.stat(_received(dest, source, "f.txt"))
|
||||
assert st.st_mode & 0o777 == 0o750, f"mode lost under {extra}"
|
||||
assert abs(st.st_mtime - DISTINCT_MTIME) < 2, f"mtime lost under {extra}"
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@pytest.mark.ci
|
||||
def test_specials_p_sanitizes_fifo_group_other_write(self):
|
||||
source = os.path.join(TEST_DATA_DIR, "specialmode_src")
|
||||
dest = os.path.join(TEST_DATA_DIR, "specialmode_dst")
|
||||
clean_dir(source)
|
||||
clean_dir(dest)
|
||||
|
||||
src_fifo = os.path.join(source, "world.fifo")
|
||||
os.mkfifo(src_fifo)
|
||||
os.chmod(src_fifo, 0o777)
|
||||
assert os.stat(src_fifo).st_mode & 0o777 == 0o777
|
||||
|
||||
# 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.
|
||||
server = ServerManager()
|
||||
saved_umask = os.umask(0)
|
||||
try:
|
||||
server.start(extra_args=["--allow-super"])
|
||||
finally:
|
||||
os.umask(saved_umask)
|
||||
try:
|
||||
result, _ = run_client(source, dest, flags=["--specials", "-p"],
|
||||
port=server.port)
|
||||
finally:
|
||||
server.stop()
|
||||
|
||||
assert result.returncode == 0, \
|
||||
f"--specials -p failed: {(result.stderr or result.stdout)[:300]}"
|
||||
|
||||
received = _received(dest, source, "world.fifo")
|
||||
assert os.path.lexists(received), "source FIFO was not recreated on the destination"
|
||||
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)}"
|
||||
+321
-7
@@ -10,6 +10,7 @@
|
||||
#include "test_utils.h"
|
||||
#include "utils.h"
|
||||
#include <pwd.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
@@ -200,9 +201,19 @@ static void test_cli_archive_flags() {
|
||||
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->follow_symlinks);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
EXPECT_TRUE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->preserve_times);
|
||||
EXPECT_TRUE(cfg->preserve_owner);
|
||||
EXPECT_TRUE(cfg->preserve_group);
|
||||
EXPECT_TRUE(cfg->preserve_devices);
|
||||
EXPECT_TRUE(cfg->preserve_specials);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
EXPECT_FALSE(cfg->preserve_acls);
|
||||
EXPECT_FALSE(cfg->preserve_xattrs);
|
||||
EXPECT_FALSE(cfg->use_xattrs);
|
||||
EXPECT_FALSE(cfg->preserve_atimes);
|
||||
EXPECT_FALSE(cfg->preserve_crtimes);
|
||||
EXPECT_FALSE(cfg->preserve_hard_links);
|
||||
EXPECT_FALSE(cfg->use_compression);
|
||||
EXPECT_FALSE(cfg->use_multithreading);
|
||||
|
||||
@@ -306,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.21.0"};
|
||||
"--dest-dir", "/dst", "--protocol=2.22.0"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv_equals, positional_args, &positional_count), 0);
|
||||
@@ -316,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.21.0"};
|
||||
"/dst", "--protocol", "2.22.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);
|
||||
@@ -326,9 +337,9 @@ static void test_parse_args_protocol_accept_current() {
|
||||
/* Any --protocol value other than the current PROTOCOL_VERSION must end in
|
||||
* failure (parse_args simply stores it; validate_config rejects it up front). */
|
||||
static void test_parse_args_protocol_rejects_other_versions() {
|
||||
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0",
|
||||
"2.17.0", "2.18.0", "2.19.0", "2.20.0",
|
||||
"216", "31", "abc", ""};
|
||||
static const char* const bad_versions[] = {"2.17", "2.16", "2.15.0", "2.16.0", "2.17.0",
|
||||
"2.18.0", "2.19.0", "2.20.0", "2.21.0", "216",
|
||||
"31", "abc", ""};
|
||||
for (size_t i = 0; i < sizeof(bad_versions) / sizeof(bad_versions[0]); i++) {
|
||||
Config* cfg = valid_client_config();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
@@ -373,6 +384,7 @@ static void test_parse_args_xattrs_acls() {
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->preserve_xattrs);
|
||||
EXPECT_FALSE(cfg->preserve_acls);
|
||||
EXPECT_FALSE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->use_xattrs);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
@@ -382,6 +394,7 @@ static void test_parse_args_xattrs_acls() {
|
||||
char* argv_long[] = {"fastsync", "--acls", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_long, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->preserve_acls);
|
||||
EXPECT_TRUE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->use_xattrs);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
@@ -392,6 +405,7 @@ static void test_parse_args_xattrs_acls() {
|
||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv_neg, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(cfg->preserve_xattrs);
|
||||
EXPECT_TRUE(cfg->preserve_acls);
|
||||
EXPECT_TRUE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->use_xattrs);
|
||||
config_delete(cfg);
|
||||
}
|
||||
@@ -503,6 +517,7 @@ static void test_parse_args_executability() {
|
||||
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->use_executability);
|
||||
EXPECT_FALSE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
|
||||
config_delete(cfg);
|
||||
@@ -515,6 +530,7 @@ static void test_parse_args_chmod() {
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->chmod_spec, "u=rw,go=r");
|
||||
EXPECT_TRUE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
mode_t result;
|
||||
EXPECT_TRUE(chmod_apply(0777, cfg->chmod_spec, &result));
|
||||
@@ -529,6 +545,7 @@ static void test_parse_args_numeric_chmod() {
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_EQ_STR(cfg->chmod_spec, "7777");
|
||||
EXPECT_TRUE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
}
|
||||
@@ -1280,9 +1297,13 @@ static void test_parse_args_archive() {
|
||||
int ret = parse_args(cfg, 4, argv, positional_args, &positional_count);
|
||||
EXPECT_EQ_INT(ret, 0);
|
||||
EXPECT_TRUE(cfg->follow_symlinks);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
EXPECT_TRUE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->preserve_times);
|
||||
EXPECT_TRUE(cfg->preserve_owner);
|
||||
EXPECT_TRUE(cfg->preserve_group);
|
||||
EXPECT_TRUE(cfg->preserve_devices);
|
||||
EXPECT_TRUE(cfg->preserve_specials);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
EXPECT_FALSE(cfg->use_compression);
|
||||
EXPECT_FALSE(cfg->use_multithreading);
|
||||
|
||||
@@ -2659,6 +2680,7 @@ static void test_parse_args_usermap() {
|
||||
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->preserve_owner);
|
||||
EXPECT_EQ_INT(cfg->usermap_count, 1);
|
||||
EXPECT_EQ_INT(cfg->usermap[0].from, 1000);
|
||||
EXPECT_EQ_INT(cfg->usermap[0].to, 1001);
|
||||
@@ -2693,6 +2715,7 @@ static void test_parse_args_groupmap() {
|
||||
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->preserve_group);
|
||||
EXPECT_EQ_INT(cfg->groupmap_count, 1);
|
||||
EXPECT_EQ_INT(cfg->groupmap[0].from, 100);
|
||||
EXPECT_EQ_INT(cfg->groupmap[0].to, 101);
|
||||
@@ -2724,6 +2747,8 @@ static void test_parse_args_chown() {
|
||||
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->preserve_owner);
|
||||
EXPECT_TRUE(cfg->preserve_group);
|
||||
EXPECT_TRUE(cfg->chown_uid_set);
|
||||
EXPECT_EQ_INT(cfg->chown_uid, 1000);
|
||||
EXPECT_TRUE(cfg->chown_gid_set);
|
||||
@@ -2736,7 +2761,9 @@ static void test_parse_args_chown() {
|
||||
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_FALSE(cfg->preserve_owner);
|
||||
EXPECT_TRUE(cfg->chown_gid_set);
|
||||
EXPECT_TRUE(cfg->preserve_group);
|
||||
EXPECT_EQ_INT(cfg->chown_gid, 1001);
|
||||
config_delete(cfg);
|
||||
|
||||
@@ -2746,8 +2773,10 @@ static void test_parse_args_chown() {
|
||||
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_TRUE(cfg->preserve_owner);
|
||||
EXPECT_EQ_INT(cfg->chown_uid, 1000);
|
||||
EXPECT_FALSE(cfg->chown_gid_set);
|
||||
EXPECT_FALSE(cfg->preserve_group);
|
||||
config_delete(cfg);
|
||||
|
||||
/* '*' means current user/group. */
|
||||
@@ -2894,6 +2923,8 @@ static void test_parse_args_metadata_times() {
|
||||
EXPECT_TRUE(cfg->omit_dir_times);
|
||||
EXPECT_TRUE(cfg->omit_link_times);
|
||||
EXPECT_TRUE(cfg->open_noatime);
|
||||
/* -U/-N govern atimes/crtimes only; they do NOT enable -t/--times. */
|
||||
EXPECT_FALSE(cfg->preserve_times);
|
||||
/* -U/-N carry their times inside the metadata payload, so they imply it. */
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
EXPECT_TRUE(validate_config(cfg));
|
||||
@@ -3327,6 +3358,280 @@ static void test_validate_config_dry_run_rejects_write_batch() {
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* -p/-t/-o/-g are independent per-attribute preservation flags: each sets only
|
||||
* its own bit and enables metadata transmission (config_derived_use_metadata). */
|
||||
static void test_parse_args_preserve_attributes_are_independent() {
|
||||
struct {
|
||||
const char* arg;
|
||||
size_t offset;
|
||||
} cases[] = {
|
||||
{"-p", offsetof(Config, preserve_perms)}, {"--perms", offsetof(Config, preserve_perms)},
|
||||
{"-t", offsetof(Config, preserve_times)}, {"--times", offsetof(Config, preserve_times)},
|
||||
{"-o", offsetof(Config, preserve_owner)}, {"--owner", offsetof(Config, preserve_owner)},
|
||||
{"-g", offsetof(Config, preserve_group)}, {"--group", offsetof(Config, preserve_group)},
|
||||
};
|
||||
const size_t all[] = {offsetof(Config, preserve_perms), offsetof(Config, preserve_times),
|
||||
offsetof(Config, preserve_owner), offsetof(Config, preserve_group)};
|
||||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv[] = {"fastsync", (char*)cases[i].arg, "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
for (size_t j = 0; j < sizeof(all) / sizeof(all[0]); j++) {
|
||||
bool expected = all[j] == cases[i].offset;
|
||||
EXPECT_TRUE(*(bool*)((char*)cfg + all[j]) == expected);
|
||||
}
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/* --preserve is the long-only rsync alias for perms+times (NOT owner/group). */
|
||||
static void test_parse_args_preserve_long_form() {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv[] = {"fastsync", "--preserve", "/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->preserve_perms);
|
||||
EXPECT_TRUE(cfg->preserve_times);
|
||||
EXPECT_FALSE(cfg->preserve_owner);
|
||||
EXPECT_FALSE(cfg->preserve_group);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --no-perms/--no-times/--no-owner/--no-group (long and short) clear only
|
||||
* their own attribute bit; they never set metadata_explicitly_disabled. */
|
||||
static void test_parse_args_preserve_negations() {
|
||||
struct {
|
||||
const char* arg;
|
||||
size_t offset;
|
||||
} cases[] = {
|
||||
{"--no-perms", offsetof(Config, preserve_perms)},
|
||||
{"--no-p", offsetof(Config, preserve_perms)},
|
||||
{"--no-times", offsetof(Config, preserve_times)},
|
||||
{"--no-t", offsetof(Config, preserve_times)},
|
||||
{"--no-owner", offsetof(Config, preserve_owner)},
|
||||
{"--no-o", offsetof(Config, preserve_owner)},
|
||||
{"--no-group", offsetof(Config, preserve_group)},
|
||||
{"--no-g", offsetof(Config, preserve_group)},
|
||||
};
|
||||
const size_t all[] = {offsetof(Config, preserve_perms), offsetof(Config, preserve_times),
|
||||
offsetof(Config, preserve_owner), offsetof(Config, preserve_group)};
|
||||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv[] = {"fastsync", "-a", (char*)cases[i].arg, "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv, positional_args, &positional_count), 0);
|
||||
for (size_t j = 0; j < sizeof(all) / sizeof(all[0]); j++) {
|
||||
bool expected = all[j] != cases[i].offset;
|
||||
EXPECT_TRUE(*(bool*)((char*)cfg + all[j]) == expected);
|
||||
}
|
||||
EXPECT_FALSE(cfg->metadata_explicitly_disabled);
|
||||
/* -a's devices/specials keep the metadata frame on. */
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Negations are order-dependent like rsync: -a after --no-owner re-enables it. */
|
||||
static void test_parse_args_preserve_negation_order() {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv1[] = {"fastsync", "-a", "--no-owner", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv1, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(cfg->preserve_owner);
|
||||
EXPECT_TRUE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->preserve_times);
|
||||
EXPECT_TRUE(cfg->preserve_group);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
positional_count = 0;
|
||||
char* argv2[] = {"fastsync", "--no-owner", "-a", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->preserve_owner);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* --no-preserve clears the whole four-attribute bundle and records the explicit
|
||||
* metadata opt-out, so the incremental/delta implication stays off. */
|
||||
static void test_parse_args_no_preserve_disables_bundle() {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv[] = {"fastsync", "--incremental", "--preserve", "--no-preserve", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 6, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(cfg->preserve_perms);
|
||||
EXPECT_FALSE(cfg->preserve_times);
|
||||
EXPECT_FALSE(cfg->preserve_owner);
|
||||
EXPECT_FALSE(cfg->preserve_group);
|
||||
EXPECT_TRUE(cfg->metadata_explicitly_disabled);
|
||||
EXPECT_TRUE(cfg->use_incremental);
|
||||
EXPECT_FALSE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* MAJOR 4: --incremental/--delta historically auto-enabled mode+mtime
|
||||
* preservation (README: "--incremental Auto-enables --preserve"), while an
|
||||
* explicit per-attribute negation must still win. */
|
||||
static void test_parse_args_incremental_implies_preserve() {
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
|
||||
/* --incremental alone implies both perms and times. */
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv[] = {"fastsync", "--incremental", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->preserve_times);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
|
||||
/* --incremental --no-perms keeps the auto-preserved times but not perms. */
|
||||
cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
positional_count = 0;
|
||||
char* argv2[] = {"fastsync", "--incremental", "--no-perms", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv2, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->preserve_times);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
|
||||
/* --incremental --no-times keeps perms but not times. */
|
||||
cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
positional_count = 0;
|
||||
char* argv3[] = {"fastsync", "--incremental", "--no-times", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv3, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->preserve_perms);
|
||||
EXPECT_FALSE(cfg->preserve_times);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
|
||||
/* --incremental --no-preserve turns the whole implication off. */
|
||||
cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
positional_count = 0;
|
||||
char* argv4[] = {"fastsync", "--incremental", "--no-preserve", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 5, argv4, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(cfg->preserve_perms);
|
||||
EXPECT_FALSE(cfg->preserve_times);
|
||||
EXPECT_TRUE(cfg->metadata_explicitly_disabled);
|
||||
EXPECT_FALSE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* config_derived_use_metadata via the CLI: representative options that turn the
|
||||
* transport bit on, and a bare run / --no-preserve that leave it off. */
|
||||
static void test_parse_args_derived_use_metadata() {
|
||||
static const char* const true_args[] = {"-p", "-t",
|
||||
"-o", "-g",
|
||||
"-U", "-N",
|
||||
"-E", "--chmod=u=rw",
|
||||
"--fake-super", "-D",
|
||||
"--devices", "-X",
|
||||
"-A", "--copy-as=@1:@1",
|
||||
"-u", "--incremental",
|
||||
"--delta"};
|
||||
for (size_t i = 0; i < sizeof(true_args) / sizeof(true_args[0]); i++) {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv[] = {"fastsync", (char*)true_args[i], "/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);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* A bare run does not derive metadata. */
|
||||
Config* bare = config_create();
|
||||
EXPECT_NOT_NULL(bare);
|
||||
char* bare_argv[] = {"fastsync", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(bare, 3, bare_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(bare->use_metadata);
|
||||
config_delete(bare);
|
||||
|
||||
/* --no-preserve suppresses the derived bit entirely. */
|
||||
Config* neg = config_create();
|
||||
EXPECT_NOT_NULL(neg);
|
||||
positional_count = 0;
|
||||
char* neg_argv[] = {"fastsync", "-p", "--no-preserve", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(neg, 5, neg_argv, positional_args, &positional_count), 0);
|
||||
EXPECT_FALSE(neg->use_metadata);
|
||||
config_delete(neg);
|
||||
}
|
||||
|
||||
/* -U/--atimes and -N/--crtimes govern only their own time attribute: each
|
||||
* carries its time inside the metadata payload (so it enables metadata
|
||||
* transmission), but neither may imply -t/--times. */
|
||||
static void test_parse_args_atimes_crtimes_do_not_imply_times() {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv_short_u[] = {"fastsync", "-U", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_short_u, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->preserve_atimes);
|
||||
EXPECT_FALSE(cfg->preserve_times);
|
||||
EXPECT_FALSE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
positional_count = 0;
|
||||
char* argv_long_n[] = {"fastsync", "--crtimes", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_long_n, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->preserve_crtimes);
|
||||
EXPECT_FALSE(cfg->preserve_times);
|
||||
EXPECT_FALSE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->use_metadata);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
/* -A/--acls implies --perms (ACL application goes through the mode path);
|
||||
* -X/--xattrs preserves only the extended attributes and must NOT set
|
||||
* preserve_perms. Either enables the derived xattr transport bit. */
|
||||
static void test_parse_args_acls_implies_perms_xattrs_does_not() {
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
char* argv_x[] = {"fastsync", "-X", "/src", "/dst"};
|
||||
int positional_args[2];
|
||||
int positional_count = 0;
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_x, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->preserve_xattrs);
|
||||
EXPECT_FALSE(cfg->preserve_acls);
|
||||
EXPECT_FALSE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->use_xattrs);
|
||||
config_delete(cfg);
|
||||
|
||||
cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
positional_count = 0;
|
||||
char* argv_a[] = {"fastsync", "-A", "/src", "/dst"};
|
||||
EXPECT_EQ_INT(parse_args(cfg, 4, argv_a, positional_args, &positional_count), 0);
|
||||
EXPECT_TRUE(cfg->preserve_acls);
|
||||
EXPECT_TRUE(cfg->preserve_perms);
|
||||
EXPECT_TRUE(cfg->use_xattrs);
|
||||
config_delete(cfg);
|
||||
}
|
||||
|
||||
void test_client_cli() {
|
||||
test_validate_config_required_paths();
|
||||
test_parse_args_numeric_ids();
|
||||
@@ -3412,6 +3717,15 @@ void test_client_cli() {
|
||||
test_parse_args_info_verbose_order();
|
||||
test_parse_args_rejects_invalid_info_flag();
|
||||
test_parse_args_archive();
|
||||
test_parse_args_preserve_attributes_are_independent();
|
||||
test_parse_args_preserve_long_form();
|
||||
test_parse_args_preserve_negations();
|
||||
test_parse_args_preserve_negation_order();
|
||||
test_parse_args_no_preserve_disables_bundle();
|
||||
test_parse_args_incremental_implies_preserve();
|
||||
test_parse_args_derived_use_metadata();
|
||||
test_parse_args_atimes_crtimes_do_not_imply_times();
|
||||
test_parse_args_acls_implies_perms_xattrs_does_not();
|
||||
test_parse_args_negations();
|
||||
test_parse_args_negate_preserve_without_devices();
|
||||
test_parse_args_negation_order();
|
||||
|
||||
+210
-8
@@ -9,6 +9,7 @@
|
||||
#include "test_utils.h"
|
||||
#include "utils.h"
|
||||
#include <signal.h>
|
||||
#include <stddef.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/socket.h>
|
||||
#include <string.h>
|
||||
@@ -1292,6 +1293,9 @@ static void test_config_metadata_times_wire_roundtrip() {
|
||||
send_cfg->preserve_crtimes = true;
|
||||
send_cfg->omit_dir_times = true;
|
||||
send_cfg->omit_link_times = true;
|
||||
/* The preservation attributes now require the metadata frame to travel
|
||||
* (config_invariants_error rejects them otherwise). */
|
||||
send_cfg->use_metadata = true;
|
||||
/* --open-noatime is client-only and must NOT cross the wire. */
|
||||
send_cfg->open_noatime = true;
|
||||
|
||||
@@ -2056,6 +2060,46 @@ static void test_identity_ownership_requested() {
|
||||
config_delete(gm);
|
||||
}
|
||||
|
||||
/* The narrow client-CHOSEN ownership predicate the daemon module gate refuses:
|
||||
* a preserve-source -o/-g (or -a) must NOT be in it (it is handled by forcing
|
||||
* super-user activity off instead), while every explicit identity flag is. */
|
||||
static void test_identity_explicit_ownership_requested() {
|
||||
EXPECT_FALSE(identity_explicit_ownership_requested(NULL));
|
||||
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
EXPECT_FALSE(identity_explicit_ownership_requested(c));
|
||||
c->preserve_owner = true;
|
||||
EXPECT_FALSE(identity_explicit_ownership_requested(c));
|
||||
EXPECT_TRUE(identity_ownership_requested(c)); /* general awareness does see -o */
|
||||
c->preserve_group = true;
|
||||
EXPECT_FALSE(identity_explicit_ownership_requested(c));
|
||||
c->preserve_owner = false;
|
||||
c->preserve_group = false;
|
||||
|
||||
c->numeric_ids = true;
|
||||
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||
c->numeric_ids = false;
|
||||
c->chown_uid_set = true;
|
||||
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||
c->chown_uid_set = false;
|
||||
c->chown_gid_set = true;
|
||||
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||
c->chown_gid_set = false;
|
||||
c->copy_as_set = true;
|
||||
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||
c->copy_as_set = false;
|
||||
c->fake_super = true;
|
||||
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||
c->fake_super = false;
|
||||
c->super_mode = SUPER_MODE_ON;
|
||||
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||
c->super_mode = SUPER_MODE_AUTO;
|
||||
EXPECT_EQ_INT(identity_parse_map(c, "@1:@2", false), 0);
|
||||
EXPECT_TRUE(identity_explicit_ownership_requested(c));
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
/* P7 Wave E hardening (A3): --super no longer implies raw numeric-id
|
||||
preservation, so it must never enable ownership application on its own; an
|
||||
explicit identity flag is required. */
|
||||
@@ -2073,6 +2117,32 @@ static void test_super_does_not_imply_numeric() {
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
/* The preserve-source -o/-g requests enable ownership application through the
|
||||
* active snapshot (identity_active_enabled) even though they are deliberately
|
||||
* absent from the narrow client-chosen identity_explicit_ownership_requested()
|
||||
* gate. */
|
||||
static void test_identity_active_enabled_includes_preserve_attrs() {
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->use_metadata = true;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_FALSE(identity_active_enabled());
|
||||
|
||||
c->preserve_owner = true;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(identity_active_enabled());
|
||||
EXPECT_FALSE(identity_explicit_ownership_requested(c));
|
||||
|
||||
c->preserve_owner = false;
|
||||
c->preserve_group = true;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(identity_active_enabled());
|
||||
EXPECT_FALSE(identity_explicit_ownership_requested(c));
|
||||
|
||||
identity_clear_active();
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
/* The single shared predicate must reject every cross-field combination the
|
||||
client/server enforce and accept a plain valid config. Because both
|
||||
validate_config() (client) and validate_received_config() (server) call it,
|
||||
@@ -2193,6 +2263,95 @@ static void test_config_invariants_error_all_combinations() {
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
/* Every per-attribute preservation flag requires the metadata frame to travel:
|
||||
* the invariant rejects any of them while use_metadata is false, and setting
|
||||
* use_metadata clears the violation. */
|
||||
static void test_config_preservation_requires_metadata() {
|
||||
static const size_t attrs[] = {
|
||||
offsetof(Config, preserve_perms), offsetof(Config, preserve_times),
|
||||
offsetof(Config, preserve_owner), offsetof(Config, preserve_group),
|
||||
offsetof(Config, preserve_atimes), offsetof(Config, preserve_crtimes),
|
||||
offsetof(Config, use_executability),
|
||||
};
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
EXPECT_NULL(config_invariants_error(c));
|
||||
for (size_t i = 0; i < sizeof(attrs) / sizeof(attrs[0]); i++) {
|
||||
bool* field = (bool*)((char*)c + attrs[i]);
|
||||
*field = true;
|
||||
EXPECT_NOT_NULL(config_invariants_error(c));
|
||||
c->use_metadata = true;
|
||||
EXPECT_NULL(config_invariants_error(c));
|
||||
c->use_metadata = false;
|
||||
*field = false;
|
||||
}
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
/* config_derived_use_metadata is the single source of truth for the derived
|
||||
* transport bit: each representative flag turns it on, and it stays off for a
|
||||
* bare config (numeric_ids alone, omit flags, whole-file, ...). */
|
||||
static void test_config_derived_use_metadata() {
|
||||
static const size_t true_flags[] = {
|
||||
offsetof(Config, preserve_perms), offsetof(Config, preserve_times),
|
||||
offsetof(Config, preserve_owner), offsetof(Config, preserve_group),
|
||||
offsetof(Config, preserve_atimes), offsetof(Config, preserve_crtimes),
|
||||
offsetof(Config, use_executability), offsetof(Config, preserve_xattrs),
|
||||
offsetof(Config, preserve_acls), offsetof(Config, fake_super),
|
||||
offsetof(Config, preserve_devices), offsetof(Config, preserve_specials),
|
||||
offsetof(Config, copy_devices), offsetof(Config, write_devices),
|
||||
offsetof(Config, copy_as_set), offsetof(Config, chown_uid_set),
|
||||
offsetof(Config, chown_gid_set), offsetof(Config, update),
|
||||
};
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
EXPECT_FALSE(config_derived_use_metadata(c));
|
||||
EXPECT_FALSE(config_derived_use_metadata(NULL));
|
||||
for (size_t i = 0; i < sizeof(true_flags) / sizeof(true_flags[0]); i++) {
|
||||
bool* field = (bool*)((char*)c + true_flags[i]);
|
||||
*field = true;
|
||||
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||
*field = false;
|
||||
}
|
||||
|
||||
/* A non-empty --chmod spec. */
|
||||
c->chmod_spec = str_dup("u=rw");
|
||||
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||
free(c->chmod_spec);
|
||||
c->chmod_spec = NULL;
|
||||
|
||||
/* Identity-map counts. */
|
||||
c->usermap_count = 1;
|
||||
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||
c->usermap_count = 0;
|
||||
c->groupmap_count = 1;
|
||||
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||
c->groupmap_count = 0;
|
||||
|
||||
/* Incremental/delta imply metadata unless --no-preserve disabled it. */
|
||||
c->use_incremental = true;
|
||||
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||
c->metadata_explicitly_disabled = true;
|
||||
EXPECT_FALSE(config_derived_use_metadata(c));
|
||||
c->metadata_explicitly_disabled = false;
|
||||
c->use_incremental = false;
|
||||
c->use_delta = true;
|
||||
EXPECT_TRUE(config_derived_use_metadata(c));
|
||||
c->metadata_explicitly_disabled = true;
|
||||
EXPECT_FALSE(config_derived_use_metadata(c));
|
||||
c->metadata_explicitly_disabled = false;
|
||||
c->use_delta = false;
|
||||
|
||||
/* Flags that must NOT imply metadata on their own. */
|
||||
c->numeric_ids = true;
|
||||
c->omit_dir_times = true;
|
||||
c->omit_link_times = true;
|
||||
c->whole_file = true;
|
||||
c->ignore_times = true;
|
||||
EXPECT_FALSE(config_derived_use_metadata(c));
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
/* The receiver previously missed several of these; a forged frame that sets
|
||||
the offending serialized fields must now be refused at the config
|
||||
handshake. (whole_file is client-only, so its rules cannot appear here.) */
|
||||
@@ -2355,6 +2514,34 @@ static void test_config_wire_roundtrip_all_fields() {
|
||||
config_delete(populated);
|
||||
}
|
||||
|
||||
/* Each of the four split-out preservation bools must survive a frame
|
||||
* round-trip on its own. The all-fields golden sets an alternating
|
||||
* true/false pattern precisely because a run of identical adjacent booleans
|
||||
* would let a same-KIND field swap produce the same bytes; isolating one true
|
||||
* bit at a time pins each new field's position and width independently. */
|
||||
static void test_config_preserve_attribute_wire_roundtrip() {
|
||||
if (is_running_under_valgrind())
|
||||
return;
|
||||
static const size_t attrs[] = {
|
||||
offsetof(Config, preserve_perms),
|
||||
offsetof(Config, preserve_times),
|
||||
offsetof(Config, preserve_owner),
|
||||
offsetof(Config, preserve_group),
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(attrs) / sizeof(attrs[0]); i++) {
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->send_directory = str_dup("/src");
|
||||
c->receive_root_directory = str_dup("/dst");
|
||||
/* The preservation invariant requires the metadata frame to travel, so set
|
||||
* the transport bit; otherwise config_receive() legitimately refuses. */
|
||||
c->use_metadata = true;
|
||||
*(bool*)((char*)c + attrs[i]) = true;
|
||||
EXPECT_TRUE(roundtrip_and_compare(c));
|
||||
config_delete(c);
|
||||
}
|
||||
}
|
||||
|
||||
/* Populate every serialized field with a non-default value so the wire frame
|
||||
* exercises each table entry. Boolean runs deliberately alternate true/false:
|
||||
* a run of identical booleans would make an adjacent swap (same KIND) produce
|
||||
@@ -2427,7 +2614,7 @@ static void golden_config_populate(Config* c) {
|
||||
c->modify_window = 3;
|
||||
c->compress_choice = str_dup("zstd");
|
||||
/* "u=rwx,go=rx" is the same 11 bytes as the original "u=rwX,go=rX" (so the
|
||||
* frame stays 637 bytes) but X is not in FastSync's chmod grammar, and the
|
||||
* frame stays 653 bytes) but X is not in FastSync's chmod grammar, and the
|
||||
* receive-side golden validates the frame. */
|
||||
c->chmod_spec = str_dup("u=rwx,go=rx");
|
||||
c->skip_compress_set = true;
|
||||
@@ -2459,6 +2646,12 @@ static void golden_config_populate(Config* c) {
|
||||
c->preserve_crtimes = false;
|
||||
c->omit_dir_times = true;
|
||||
c->omit_link_times = false;
|
||||
/* Mixed true/false so a field reorder or a dropped attribute changes the
|
||||
* pinned hash rather than passing silently. */
|
||||
c->preserve_perms = true;
|
||||
c->preserve_times = false;
|
||||
c->preserve_owner = true;
|
||||
c->preserve_group = false;
|
||||
c->munge_links = true;
|
||||
c->keep_dirlinks = false;
|
||||
c->fake_super = true;
|
||||
@@ -2472,13 +2665,14 @@ static void golden_config_populate(Config* c) {
|
||||
c->copy_as_gid = 222;
|
||||
}
|
||||
|
||||
/* The pinned golden frame (protocol 2.21.0). The values below are the only
|
||||
/* The pinned golden frame (protocol 2.22.0). The values below are the only
|
||||
* thing that ties the generated table to the historical wire format; update
|
||||
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The combined
|
||||
* 2.21.0 wave appends the serialized dry_run bool to CONFIG_WIRE_CORE_FIELDS
|
||||
* and keeps the protocol version string at 2.21.0. */
|
||||
#define GOLDEN_WIRE_LEN 637
|
||||
#define GOLDEN_WIRE_HASH 13228626061067899189ULL
|
||||
* them ONLY with a PROTOCOL_VERSION bump and a documented reason. The 2.22.0
|
||||
* preserve-attribute split appends four serialized bools
|
||||
* (preserve_perms/times/owner/group) to CONFIG_WIRE_METADATA_TIMES_FIELDS after
|
||||
* omit_link_times. */
|
||||
#define GOLDEN_WIRE_LEN 653
|
||||
#define GOLDEN_WIRE_HASH 95530566005420798ULL
|
||||
|
||||
static unsigned long long fnv1a_64(const unsigned char* buf, size_t len) {
|
||||
unsigned long long h = 1469598103934665603ULL;
|
||||
@@ -2560,7 +2754,7 @@ static unsigned long long capture_wire_hash(const Config* cfg, size_t* out_len)
|
||||
return h;
|
||||
}
|
||||
|
||||
/* Byte-for-byte wire compatibility guard (protocol 2.21.0). The expected hash
|
||||
/* Byte-for-byte wire compatibility guard (protocol 2.22.0). The expected hash
|
||||
* pins the pre-X-macro byte stream; the refactor MUST NOT change it. */
|
||||
static void test_config_wire_golden() {
|
||||
if (is_running_under_valgrind())
|
||||
@@ -2613,6 +2807,9 @@ static void test_config_wire_golden_receive() {
|
||||
!recv->use_multithreading;
|
||||
ok = ok && recv->compression_level == 7 && recv->chunk_size == 65536;
|
||||
ok = ok && recv->use_delta && !recv->whole_file && recv->use_xattrs;
|
||||
/* Per-attribute preservation split decoded from the pinned bytes. */
|
||||
ok = ok && recv->preserve_perms && !recv->preserve_times && recv->preserve_owner &&
|
||||
!recv->preserve_group;
|
||||
/* Bounded/validated KINDs decoded from the pinned bytes. */
|
||||
ok = ok && recv->checksum_algo == CHECKSUM_ALGO_MD5;
|
||||
ok = ok && recv->super_mode == SUPER_MODE_ON;
|
||||
@@ -2867,15 +3064,20 @@ void test_config() {
|
||||
test_config_receive_rejects_oversized_string_budget();
|
||||
test_config_receive_with_validate_rejects();
|
||||
test_config_invariants_error_all_combinations();
|
||||
test_config_preservation_requires_metadata();
|
||||
test_config_derived_use_metadata();
|
||||
test_config_receive_rejects_unified_invariants();
|
||||
test_config_wire_golden();
|
||||
test_config_wire_golden_receive();
|
||||
test_config_wire_receive_bounds();
|
||||
test_config_receive_rejects_overcap_counts();
|
||||
test_config_wire_roundtrip_all_fields();
|
||||
test_config_preserve_attribute_wire_roundtrip();
|
||||
}
|
||||
test_identity_copy_as_refused();
|
||||
test_identity_ownership_requested();
|
||||
test_identity_explicit_ownership_requested();
|
||||
test_identity_active_enabled_includes_preserve_attrs();
|
||||
test_super_does_not_imply_numeric();
|
||||
test_privilege_super_permitted_modes();
|
||||
test_config_delete_timing_early_helper();
|
||||
|
||||
+194
-11
@@ -410,7 +410,7 @@ static void test_file_write_to_disk_with_fsync() {
|
||||
const char* path = "test_file_write_to_disk_fsync.txt";
|
||||
const char* content = "fsync file content";
|
||||
EXPECT_TRUE(file_to_disk_secure_with_fsync(path, content, strlen(content), false, false, false,
|
||||
NULL, false, true, NULL));
|
||||
NULL, (FileAttrPolicy){0}, true, NULL));
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_size, (int)strlen(content));
|
||||
@@ -420,8 +420,8 @@ static void test_file_write_to_disk_with_fsync() {
|
||||
static void test_file_write_to_disk_preallocate_atomic() {
|
||||
const char* path = "test_file_write_prealloc_atomic.txt";
|
||||
const char* content = "prealloc atomic content";
|
||||
EXPECT_TRUE(
|
||||
file_to_disk_secure(path, content, strlen(content), false, false, true, NULL, false, NULL));
|
||||
EXPECT_TRUE(file_to_disk_secure(path, content, strlen(content), false, false, true, NULL,
|
||||
(FileAttrPolicy){0}, NULL));
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_size, (int)strlen(content));
|
||||
@@ -438,8 +438,8 @@ static void test_file_write_to_disk_preallocate_atomic() {
|
||||
static void test_file_write_to_disk_preallocate_inplace() {
|
||||
const char* path = "test_file_write_prealloc_inplace.txt";
|
||||
const char* content = "prealloc inplace content";
|
||||
EXPECT_TRUE(
|
||||
file_to_disk_secure(path, content, strlen(content), true, false, true, NULL, false, NULL));
|
||||
EXPECT_TRUE(file_to_disk_secure(path, content, strlen(content), true, false, true, NULL,
|
||||
(FileAttrPolicy){0}, NULL));
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_size, (int)strlen(content));
|
||||
@@ -951,6 +951,178 @@ static void test_inplace_overwrite_metadata_strips_special_bits() {
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* The per-attribute split: with no -p/-E the atomic (inode-replacing) write
|
||||
* must restore the PRE-EXISTING destination mode instead of the source mode; a
|
||||
* brand-new file keeps the historical 0644 default; -p applies the source. */
|
||||
static void test_atomic_no_perms_preserves_destination_mode() {
|
||||
const char* path = "test_attr_split_mode.txt";
|
||||
unlink(path);
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0640);
|
||||
EXPECT_TRUE(fd >= 0);
|
||||
/* cppcheck-suppress knownConditionTrueFalse */
|
||||
if (fd < 0)
|
||||
return;
|
||||
EXPECT_EQ_INT(fchmod(fd, 0640), 0);
|
||||
EXPECT_EQ_INT(close(fd), 0);
|
||||
|
||||
FileMetadata m;
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.mode = 0755;
|
||||
m.uid = geteuid();
|
||||
m.gid = getegid();
|
||||
m.mtime_sec = 1700000000;
|
||||
|
||||
/* No -p/-E: the pre-existing 0640 survives the atomic overwrite. */
|
||||
bool ok = file_to_disk_secure_attrs(path, "data", 4, false, false, false, &m,
|
||||
(FileAttrPolicy){false, false, false, false}, false, false,
|
||||
false, NULL, false, false, NULL);
|
||||
EXPECT_TRUE(ok);
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0640);
|
||||
|
||||
/* -p: the source mode wins. */
|
||||
ok = file_to_disk_secure_attrs(path, "data2", 5, false, false, false, &m,
|
||||
(FileAttrPolicy){true, true, false, false}, false, false, false,
|
||||
NULL, false, false, NULL);
|
||||
EXPECT_TRUE(ok);
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0755);
|
||||
|
||||
/* -E only (rsync rule): an executable source derives exec from the
|
||||
pre-existing destination's read bits. Dest 0640 (owner+group read) with a
|
||||
source 0755 gives 0750, not 0751 and not the scratch 0711. */
|
||||
EXPECT_EQ_INT(chmod(path, 0640), 0);
|
||||
ok = file_to_disk_secure_attrs(path, "data3", 6, false, false, false, &m,
|
||||
(FileAttrPolicy){false, false, false, true}, false, false, false,
|
||||
NULL, false, false, NULL);
|
||||
EXPECT_TRUE(ok);
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0750);
|
||||
|
||||
unlink(path);
|
||||
|
||||
/* A brand-new file with no -p uses rsync's source&~umask base when metadata
|
||||
is available (m.mode is 0755 here). */
|
||||
const char* fresh = "test_attr_split_fresh.txt";
|
||||
unlink(fresh);
|
||||
ok = file_to_disk_secure_attrs(fresh, "data", 4, false, false, false, &m,
|
||||
(FileAttrPolicy){false, false, false, false}, false, false, false,
|
||||
NULL, false, false, NULL);
|
||||
EXPECT_TRUE(ok);
|
||||
EXPECT_EQ_INT(stat(fresh, &st), 0);
|
||||
EXPECT_EQ_INT((int)(st.st_mode & 0777), (int)(m.mode & 0777 & ~(mode_t)file_process_umask()));
|
||||
unlink(fresh);
|
||||
|
||||
/* Without any metadata the historical fixed 0644 default still applies. */
|
||||
unlink(fresh);
|
||||
ok = file_to_disk_secure_attrs(fresh, "data", 4, false, false, false, NULL,
|
||||
(FileAttrPolicy){false, false, false, false}, false, false, false,
|
||||
NULL, false, false, NULL);
|
||||
EXPECT_TRUE(ok);
|
||||
EXPECT_EQ_INT(stat(fresh, &st), 0);
|
||||
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0644);
|
||||
unlink(fresh);
|
||||
}
|
||||
|
||||
/* MAJOR 2: a brand-new destination file must never be created group/other
|
||||
* writable from a client-supplied source mode. The daemon runs with umask(0),
|
||||
* so without the explicit S_IWGRP|S_IWOTH strip a source 0666 (with no -p)
|
||||
* would materialize as world-writable. */
|
||||
static void test_new_file_mode_never_group_other_writable() {
|
||||
const char* path = "test_new_file_no_go_write.bin";
|
||||
unlink(path);
|
||||
FileMetadata m;
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.mode = 0666; /* maximal group/other write in the source mode */
|
||||
m.uid = geteuid();
|
||||
m.gid = getegid();
|
||||
|
||||
bool ok = file_to_disk_secure_attrs(path, "x", 1, false, false, false, &m,
|
||||
(FileAttrPolicy){false, false, false, false}, false, false,
|
||||
false, NULL, false, false, NULL);
|
||||
EXPECT_TRUE(ok);
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT((int)(st.st_mode & (S_IWGRP | S_IWOTH)), 0);
|
||||
/* The rest of the source mode is still honored (owner write survives). */
|
||||
EXPECT_EQ_INT((int)(st.st_mode & S_IWUSR), S_IWUSR);
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
/* Security: a client-supplied special-node mode must never materialize a
|
||||
* group/other-writable FIFO. file_save_special_to_disk() sanitizes the
|
||||
* creation bits the same way the regular-file policy does: under -p the source
|
||||
* mode loses S_IWGRP|S_IWOTH (0777 -> 0755), and without -p a safe 0644 default
|
||||
* is used. The daemon runs with umask(0) (server.c), so the explicit strip is
|
||||
* what keeps the node safe -- the test clears the umask to prove it. */
|
||||
static void test_special_fifo_mode_never_group_other_writable_impl() {
|
||||
const char* root = "test_special_mode_tmp";
|
||||
const char* with_p = "test_special_mode_tmp/with_p.fifo";
|
||||
const char* no_p = "test_special_mode_tmp/no_p.fifo";
|
||||
unlink(with_p);
|
||||
unlink(no_p);
|
||||
rmdir(root);
|
||||
EXPECT_EQ_INT(mkdir(root, 0700), 0);
|
||||
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
|
||||
FileMetadata meta;
|
||||
memset(&meta, 0, sizeof(meta));
|
||||
meta.mode = S_IFIFO | 0777;
|
||||
meta.uid = geteuid();
|
||||
meta.gid = getegid();
|
||||
meta.mtime_sec = 1000000000;
|
||||
|
||||
/* -p: the source mode is honored minus group/other write. */
|
||||
File* f = file_create("with_p.fifo");
|
||||
EXPECT_NOT_NULL(f);
|
||||
f->is_special = true;
|
||||
f->metadata = &meta;
|
||||
cfg->preserve_specials = true;
|
||||
cfg->preserve_perms = true;
|
||||
cfg->use_metadata = true;
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, f, cfg), FILE_SAVE_WRITTEN);
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(lstat(with_p, &st), 0);
|
||||
EXPECT_TRUE(S_ISFIFO(st.st_mode));
|
||||
EXPECT_EQ_INT((int)(st.st_mode & (S_IWGRP | S_IWOTH)), 0);
|
||||
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0755);
|
||||
f->metadata = NULL;
|
||||
file_destroy(f);
|
||||
|
||||
/* No -p: the fixed safe default, never the source's 0777. */
|
||||
f = file_create("no_p.fifo");
|
||||
EXPECT_NOT_NULL(f);
|
||||
f->is_special = true;
|
||||
f->metadata = &meta;
|
||||
cfg->preserve_perms = false;
|
||||
EXPECT_EQ_INT(file_save_to_disk_full(root, f, cfg), FILE_SAVE_WRITTEN);
|
||||
EXPECT_EQ_INT(lstat(no_p, &st), 0);
|
||||
EXPECT_TRUE(S_ISFIFO(st.st_mode));
|
||||
EXPECT_EQ_INT((int)(st.st_mode & (S_IWGRP | S_IWOTH)), 0);
|
||||
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0644);
|
||||
f->metadata = NULL;
|
||||
file_destroy(f);
|
||||
|
||||
config_delete(cfg);
|
||||
unlink(with_p);
|
||||
unlink(no_p);
|
||||
rmdir(root);
|
||||
}
|
||||
|
||||
/* The receiver daemon runs umask(0), so an unsanitized source mode would reach
|
||||
* mkfifo unmasked. Run the body with umask(0) to exercise the explicit strip,
|
||||
* and restore the process umask from this wrapper so a failing EXPECT inside the
|
||||
* body (which returns from the body only) cannot leak umask(0) into later
|
||||
* tests. */
|
||||
static void test_special_fifo_mode_never_group_other_writable() {
|
||||
mode_t saved_umask = umask(0);
|
||||
test_special_fifo_mode_never_group_other_writable_impl();
|
||||
umask(saved_umask);
|
||||
}
|
||||
|
||||
static void test_inplace_overwrite_truncates_shorter_payload() {
|
||||
const char* root = "test_inplace_trunc_tmp";
|
||||
const char* path = "test_inplace_trunc_tmp/big.txt";
|
||||
@@ -1335,7 +1507,8 @@ static void test_file_write_to_disk_sparse_preserves_holes() {
|
||||
buf[size - 1 - i] = (unsigned char)((i * 7) % 253);
|
||||
}
|
||||
|
||||
EXPECT_TRUE(file_to_disk_secure(path, buf, size, false, true, false, NULL, false, NULL));
|
||||
EXPECT_TRUE(
|
||||
file_to_disk_secure(path, buf, size, false, true, false, NULL, (FileAttrPolicy){0}, NULL));
|
||||
|
||||
/* Logical size must equal data_size exactly. */
|
||||
struct stat st;
|
||||
@@ -1400,8 +1573,9 @@ static void test_file_write_to_disk_partial_retention() {
|
||||
m.mtime_nsec = 2000000000; /* invalid: forces futimens EINVAL after the write */
|
||||
m.atime_valid = false;
|
||||
m.crtime_valid = false;
|
||||
bool ok = file_to_disk_secure_attrs(path, content, strlen(content), false, false, true, &m, false,
|
||||
false, false, false, NULL, false, true, NULL);
|
||||
bool ok = file_to_disk_secure_attrs(path, content, strlen(content), false, false, true, &m,
|
||||
(FileAttrPolicy){true, true, false, false}, false, false,
|
||||
false, NULL, false, true, NULL);
|
||||
EXPECT_FALSE(ok); /* the write itself succeeded, but metadata restore failed */
|
||||
/* Retained: the already-written temp now sits at the destination path. */
|
||||
int fd = open(path, O_RDONLY);
|
||||
@@ -1419,8 +1593,9 @@ static void test_file_write_to_disk_partial_retention() {
|
||||
unlink(path);
|
||||
|
||||
/* Same failure with keep_partial=false: temp is unlinked, nothing retained. */
|
||||
ok = file_to_disk_secure_attrs(path, content, strlen(content), false, false, true, &m, false,
|
||||
false, false, false, NULL, false, false, NULL);
|
||||
ok = file_to_disk_secure_attrs(path, content, strlen(content), false, false, true, &m,
|
||||
(FileAttrPolicy){true, true, false, false}, false, false, false,
|
||||
NULL, false, false, NULL);
|
||||
EXPECT_FALSE(ok);
|
||||
EXPECT_TRUE(access(path, F_OK) == -1);
|
||||
}
|
||||
@@ -1444,10 +1619,15 @@ static void test_dir_time_list() {
|
||||
EXPECT_TRUE(dir_time_list_add(&list, "sub", &metadata));
|
||||
EXPECT_EQ_INT((int)list.count, 2);
|
||||
|
||||
dir_time_list_apply(&list, root);
|
||||
Config* cfg = config_create();
|
||||
EXPECT_NOT_NULL(cfg);
|
||||
cfg->use_metadata = true;
|
||||
cfg->preserve_times = true;
|
||||
dir_metadata_list_apply(&list, root, cfg);
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(sub, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_mtime, 1000000000);
|
||||
config_delete(cfg);
|
||||
|
||||
dir_time_list_free(&list);
|
||||
EXPECT_EQ_INT((int)list.count, 0);
|
||||
@@ -1694,6 +1874,9 @@ void test_file() {
|
||||
test_keep_dirlinks_secure_open();
|
||||
test_inplace_overwrite_clears_special_mode_bits();
|
||||
test_inplace_overwrite_metadata_strips_special_bits();
|
||||
test_atomic_no_perms_preserves_destination_mode();
|
||||
test_new_file_mode_never_group_other_writable();
|
||||
test_special_fifo_mode_never_group_other_writable();
|
||||
test_inplace_overwrite_truncates_shorter_payload();
|
||||
test_inplace_refuses_fifo_destination();
|
||||
test_inplace_refuses_device_destination();
|
||||
|
||||
+237
-7
@@ -1,8 +1,10 @@
|
||||
#include "test_metadata.h"
|
||||
#include "chmod.h"
|
||||
#include "identity.h"
|
||||
#include "metadata.h"
|
||||
#include "protocol.h"
|
||||
#include "test_utils.h"
|
||||
#include <fcntl.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/ioctl.h>
|
||||
@@ -337,7 +339,7 @@ static void test_file_restore_metadata_applies_atime() {
|
||||
m.crtime_sec = 0;
|
||||
m.crtime_nsec = 0;
|
||||
|
||||
file_restore_metadata(path, &m, false);
|
||||
file_restore_metadata(path, &m, (FileAttrPolicy){true, true, true, false});
|
||||
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
@@ -376,7 +378,7 @@ static void test_file_restore_metadata() {
|
||||
.atime_valid = false,
|
||||
.crtime_valid = false};
|
||||
|
||||
file_restore_metadata(path, &m, false);
|
||||
file_restore_metadata(path, &m, (FileAttrPolicy){true, true, false, false});
|
||||
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
@@ -393,7 +395,7 @@ static void test_file_restore_executability_only() {
|
||||
|
||||
FileMetadata m = {
|
||||
.mode = 0751, .uid = getuid(), .gid = getgid(), .mtime_sec = 0, .mtime_nsec = 0};
|
||||
file_restore_metadata(path, &m, true);
|
||||
file_restore_metadata(path, &m, (FileAttrPolicy){false, false, false, true});
|
||||
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
@@ -407,14 +409,163 @@ static void test_directory_restore_executability_only() {
|
||||
|
||||
FileMetadata m = {
|
||||
.mode = 0755, .uid = getuid(), .gid = getgid(), .mtime_sec = 0, .mtime_nsec = 0};
|
||||
file_restore_metadata(path, &m, true);
|
||||
file_restore_metadata(path, &m, (FileAttrPolicy){false, false, false, true});
|
||||
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT(st.st_mode & 0777, 0711);
|
||||
/* rsync -E: an executable source derives exec from the DESTINATION's read
|
||||
* bits. A 0700 directory has read only for the owner, so only the owner
|
||||
* gains exec -- the result stays 0700 (not 0711, the old per-class copy). */
|
||||
EXPECT_EQ_INT(st.st_mode & 0777, 0700);
|
||||
rmdir(path);
|
||||
}
|
||||
|
||||
/* rsync 3.4 -E truth table (preserve_perms off), verified against rsync 3.4.1:
|
||||
* (src,dest) -> result. A non-executable source clears every execute bit; an
|
||||
* executable source sets a class's execute bit iff that class can read. */
|
||||
static void test_file_restore_executability_rsync_rule() {
|
||||
static const struct {
|
||||
mode_t src;
|
||||
mode_t dest;
|
||||
mode_t want;
|
||||
} cases[] = {
|
||||
{0755, 0644, 0755}, {0755, 0600, 0700}, {0755, 0640, 0750}, {0755, 0666, 0777},
|
||||
{0700, 0640, 0750}, {0111, 0644, 0755}, {0644, 0755, 0644}, {0644, 0600, 0600},
|
||||
};
|
||||
const char* path = "temp_exec_rsync_rule.txt";
|
||||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||||
EXPECT_TRUE(file_write_to_disk(path, "x", 1, false, false));
|
||||
EXPECT_EQ_INT(chmod(path, cases[i].dest), 0);
|
||||
FileMetadata m = {.mode = cases[i].src, .uid = getuid(), .gid = getgid()};
|
||||
file_restore_metadata(path, &m, (FileAttrPolicy){false, false, false, true});
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT(st.st_mode & 0777, cases[i].want);
|
||||
unlink(path);
|
||||
}
|
||||
}
|
||||
|
||||
/* The shared metadata_mode_for_policy() helper is the single source of truth
|
||||
* used by both the normal metadata path and the --fake-super replay. It must
|
||||
* reproduce the per-attribute split: no mode change when neither -p nor -E is
|
||||
* set; -p applies the sanitized source mode (group/other write cleared)
|
||||
* regardless of the destination; -E derives exec bits from the destination and
|
||||
* --perms wins when both are set. */
|
||||
static void test_metadata_mode_for_policy() {
|
||||
mode_t out = 0xdead;
|
||||
EXPECT_FALSE(
|
||||
metadata_mode_for_policy(0777, 0644, (FileAttrPolicy){false, false, false, false}, &out));
|
||||
EXPECT_EQ_INT((int)out, 0xdead); /* untouched when no change is requested */
|
||||
|
||||
EXPECT_TRUE(
|
||||
metadata_mode_for_policy(0777, 0644, (FileAttrPolicy){true, false, false, false}, &out));
|
||||
EXPECT_EQ_INT((int)(out & 0777), 0755); /* group/other write always cleared */
|
||||
|
||||
/* -E: exec bits derive from the DESTINATION's read bits. */
|
||||
EXPECT_TRUE(
|
||||
metadata_mode_for_policy(0755, 0644, (FileAttrPolicy){false, false, false, true}, &out));
|
||||
EXPECT_EQ_INT((int)(out & 0777), 0755);
|
||||
EXPECT_TRUE(
|
||||
metadata_mode_for_policy(0644, 0755, (FileAttrPolicy){false, false, false, true}, &out));
|
||||
EXPECT_EQ_INT((int)(out & 0777), 0644);
|
||||
EXPECT_TRUE(
|
||||
metadata_mode_for_policy(0755, 0600, (FileAttrPolicy){false, false, false, true}, &out));
|
||||
EXPECT_EQ_INT((int)(out & 0777), 0700);
|
||||
|
||||
/* --perms wins over -E when both are set. */
|
||||
EXPECT_TRUE(
|
||||
metadata_mode_for_policy(0700, 0644, (FileAttrPolicy){true, false, false, true}, &out));
|
||||
EXPECT_EQ_INT((int)(out & 0777), 0700);
|
||||
}
|
||||
|
||||
/* A brand-new file with -p off is created like rsync: source_mode & 0777 &
|
||||
* ~umask (when metadata is available). The -E rule is then layered on top. */
|
||||
static void test_new_file_mode_from_source_and_umask() {
|
||||
const char* path = "temp_new_file_base.txt";
|
||||
unlink(path);
|
||||
FileMetadata m = {.mode = 0751, .uid = getuid(), .gid = getgid()};
|
||||
mode_t want = (mode_t)(0751 & 0777 & ~(mode_t)file_process_umask());
|
||||
|
||||
bool ok = file_to_disk_secure_attrs(path, "x", 1, false, false, false, &m,
|
||||
(FileAttrPolicy){false, false, false, false}, false, false,
|
||||
false, NULL, false, false, NULL);
|
||||
EXPECT_TRUE(ok);
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT(st.st_mode & 0777, want);
|
||||
unlink(path);
|
||||
|
||||
/* -E on top of the source&~umask base (src 0751, umask 022 -> 0751). */
|
||||
ok = file_to_disk_secure_attrs(path, "x", 1, false, false, false, &m,
|
||||
(FileAttrPolicy){false, false, false, true}, false, false, false,
|
||||
NULL, false, false, NULL);
|
||||
EXPECT_TRUE(ok);
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
mode_t want_e =
|
||||
(want & 0444) ? (mode_t)(want | ((want & 0444) >> 2)) : (mode_t)(want & ~(mode_t)0111);
|
||||
EXPECT_EQ_INT(st.st_mode & 0777, want_e);
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
/* The per-attribute split: -t/-U apply times without touching the mode; an
|
||||
* all-off policy applies neither mode nor times. */
|
||||
static void test_file_restore_attribute_split() {
|
||||
const char* path = "temp_meta_split_test.txt";
|
||||
EXPECT_TRUE(file_write_to_disk(path, "x", 1, false, false));
|
||||
EXPECT_EQ_INT(chmod(path, 0640), 0);
|
||||
|
||||
FileMetadata m = {.mode = 0755,
|
||||
.uid = getuid(),
|
||||
.gid = getgid(),
|
||||
.mtime_sec = 1234567890,
|
||||
.mtime_nsec = 0,
|
||||
.atime_valid = false,
|
||||
.crtime_valid = false};
|
||||
|
||||
/* times only: mtime changes, mode stays 0640. */
|
||||
file_restore_metadata(path, &m, (FileAttrPolicy){false, true, false, false});
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT(st.st_mode & 0777, 0640);
|
||||
EXPECT_EQ_INT((int)st.st_mtime, 1234567890);
|
||||
|
||||
/* no attributes: neither mode nor mtime changes. */
|
||||
EXPECT_EQ_INT(chmod(path, 0640), 0);
|
||||
struct timespec ts[2] = {{.tv_sec = 1000000000, .tv_nsec = 0},
|
||||
{.tv_sec = 1000000000, .tv_nsec = 0}};
|
||||
EXPECT_EQ_INT(utimensat(AT_FDCWD, path, ts, 0), 0);
|
||||
FileMetadata m2 = m;
|
||||
m2.mode = 0700;
|
||||
m2.mtime_sec = 1600000000;
|
||||
file_restore_metadata(path, &m2, (FileAttrPolicy){false, false, false, false});
|
||||
EXPECT_EQ_INT(stat(path, &st), 0);
|
||||
EXPECT_EQ_INT(st.st_mode & 0777, 0640);
|
||||
EXPECT_EQ_INT((int)st.st_mtime, 1000000000);
|
||||
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
static void test_file_attr_policy_from_config() {
|
||||
FileAttrPolicy none = file_attr_policy_from_config(NULL);
|
||||
EXPECT_FALSE(none.perms);
|
||||
EXPECT_FALSE(none.times);
|
||||
EXPECT_FALSE(none.atimes);
|
||||
EXPECT_FALSE(none.executability);
|
||||
|
||||
Config* c = config_create();
|
||||
EXPECT_NOT_NULL(c);
|
||||
c->preserve_perms = true;
|
||||
c->preserve_times = true;
|
||||
c->preserve_atimes = true;
|
||||
c->use_executability = true;
|
||||
FileAttrPolicy p = file_attr_policy_from_config(c);
|
||||
EXPECT_TRUE(p.perms);
|
||||
EXPECT_TRUE(p.times);
|
||||
EXPECT_TRUE(p.atimes);
|
||||
EXPECT_TRUE(p.executability);
|
||||
config_delete(c);
|
||||
}
|
||||
|
||||
static void test_chmod_changes() {
|
||||
mode_t result;
|
||||
EXPECT_TRUE(chmod_apply(0777, "u=rw,go=r", &result));
|
||||
@@ -455,7 +606,7 @@ static void test_file_restore_symlink_metadata() {
|
||||
|
||||
/* Positive path: a non-omitted apply stamps the link's own mtime. */
|
||||
FileMetadata applied = {.mtime_sec = 1000000000, .mtime_nsec = 0};
|
||||
file_restore_symlink_metadata(link, &applied, false);
|
||||
file_restore_symlink_metadata(link, &applied, (FileAttrPolicy){false, true, false, false}, false);
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(lstat(link, &st), 0);
|
||||
EXPECT_TRUE(S_ISLNK(st.st_mode));
|
||||
@@ -464,7 +615,7 @@ static void test_file_restore_symlink_metadata() {
|
||||
|
||||
/* -J: a different time must be left untouched. */
|
||||
FileMetadata newer = {.mtime_sec = 1234567890, .mtime_nsec = 0};
|
||||
file_restore_symlink_metadata(link, &newer, true);
|
||||
file_restore_symlink_metadata(link, &newer, (FileAttrPolicy){false, true, false, false}, true);
|
||||
EXPECT_EQ_INT(lstat(link, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_mtime, (int)t1);
|
||||
if (symlink_times_supported)
|
||||
@@ -475,6 +626,79 @@ static void test_file_restore_symlink_metadata() {
|
||||
rmdir(dir);
|
||||
}
|
||||
|
||||
/* Per-attribute gating of the descriptor restore path: -p alone applies the
|
||||
* mode, -t alone the mtime, -U alone the atime, and an all-off policy leaves
|
||||
* the destination's mode and times exactly as they are. This pins the fd API
|
||||
* the receiver actually uses (the path-based file_restore_metadata has its own
|
||||
* split test). */
|
||||
static void test_file_restore_metadata_fd_attribute_split() {
|
||||
identity_clear_active(); /* no ownership policy leaking from a previous test */
|
||||
const char* path = "temp_meta_fd_split_test.txt";
|
||||
EXPECT_TRUE(file_write_to_disk(path, "x", 1, false, false));
|
||||
EXPECT_EQ_INT(chmod(path, 0640), 0);
|
||||
int fd = open(path, O_RDWR);
|
||||
EXPECT_TRUE(fd >= 0);
|
||||
/* cppcheck-suppress knownConditionTrueFalse -- EXPECT_TRUE above asserts,
|
||||
but cppcheck cannot see through the macro; the guard is defensive. */
|
||||
if (fd < 0) {
|
||||
unlink(path);
|
||||
return;
|
||||
}
|
||||
|
||||
FileMetadata m = {.mode = 0755,
|
||||
.uid = getuid(),
|
||||
.gid = getgid(),
|
||||
.mtime_sec = 1234567890,
|
||||
.mtime_nsec = 0,
|
||||
.atime_valid = true,
|
||||
.atime_sec = 999999999,
|
||||
.atime_nsec = 0,
|
||||
.crtime_valid = false};
|
||||
struct stat st;
|
||||
struct stat before;
|
||||
|
||||
/* perms-only: mode applied, mtime untouched. */
|
||||
EXPECT_EQ_INT(fstat(fd, &before), 0);
|
||||
EXPECT_TRUE(file_restore_metadata_fd(fd, &m, (FileAttrPolicy){true, false, false, false}));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT(st.st_mode & 0777, 0755);
|
||||
EXPECT_EQ_INT((int)st.st_mtime, (int)before.st_mtime);
|
||||
|
||||
/* times-only: mtime applied, mode untouched. */
|
||||
EXPECT_EQ_INT(chmod(path, 0600), 0);
|
||||
EXPECT_TRUE(file_restore_metadata_fd(fd, &m, (FileAttrPolicy){false, true, false, false}));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT(st.st_mode & 0777, 0600);
|
||||
EXPECT_EQ_INT((int)st.st_mtime, 1234567890);
|
||||
|
||||
/* atime-only: atime applied, mtime and mode untouched. */
|
||||
struct timespec reset[2] = {{.tv_sec = 1000000000, .tv_nsec = 0},
|
||||
{.tv_sec = 1000000000, .tv_nsec = 0}};
|
||||
EXPECT_EQ_INT(futimens(fd, reset), 0);
|
||||
EXPECT_EQ_INT(fstat(fd, &before), 0);
|
||||
EXPECT_TRUE(file_restore_metadata_fd(fd, &m, (FileAttrPolicy){false, false, true, false}));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_atime, 999999999);
|
||||
EXPECT_EQ_INT((int)st.st_mtime, (int)before.st_mtime);
|
||||
EXPECT_EQ_INT(st.st_mode & 0777, 0600);
|
||||
|
||||
/* all-off: neither mode nor either time is touched. */
|
||||
EXPECT_EQ_INT(chmod(path, 0640), 0);
|
||||
EXPECT_EQ_INT(futimens(fd, reset), 0);
|
||||
FileMetadata m2 = m;
|
||||
m2.mode = 0700;
|
||||
m2.mtime_sec = 1600000000;
|
||||
m2.atime_sec = 1700000000;
|
||||
EXPECT_TRUE(file_restore_metadata_fd(fd, &m2, (FileAttrPolicy){false, false, false, false}));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT(st.st_mode & 0777, 0640);
|
||||
EXPECT_EQ_INT((int)st.st_mtime, 1000000000);
|
||||
EXPECT_EQ_INT((int)st.st_atime, 1000000000);
|
||||
|
||||
close(fd);
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
void test_metadata() {
|
||||
test_metadata_to_from_buf_roundtrip();
|
||||
test_metadata_to_buf_null();
|
||||
@@ -491,6 +715,12 @@ void test_metadata() {
|
||||
test_file_restore_metadata_applies_atime();
|
||||
test_file_restore_executability_only();
|
||||
test_directory_restore_executability_only();
|
||||
test_file_restore_executability_rsync_rule();
|
||||
test_metadata_mode_for_policy();
|
||||
test_new_file_mode_from_source_and_umask();
|
||||
test_file_restore_attribute_split();
|
||||
test_file_restore_metadata_fd_attribute_split();
|
||||
test_file_attr_policy_from_config();
|
||||
test_file_restore_symlink_metadata();
|
||||
test_chmod_changes();
|
||||
}
|
||||
|
||||
+70
-9
@@ -246,7 +246,8 @@ static void test_link_copy_fallback_preserves_xattrs() {
|
||||
m.atime_valid = false;
|
||||
m.crtime_valid = false;
|
||||
|
||||
bool ok = file_to_disk_secure_link_attrs(dest, basis_dir, "payload", 7, false, &m, false, false,
|
||||
bool ok = file_to_disk_secure_link_attrs(dest, basis_dir, "payload", 7, false, &m,
|
||||
(FileAttrPolicy){true, true, false, false}, false,
|
||||
xattrs, true, NULL);
|
||||
xattr_list_free(xattrs);
|
||||
EXPECT_TRUE(ok);
|
||||
@@ -367,10 +368,11 @@ static void test_fake_super_restore() {
|
||||
}
|
||||
|
||||
/* No xattr present yet: restore is a silent no-op (returns false, no crash). */
|
||||
EXPECT_FALSE(fake_super_restore_fd(fd));
|
||||
FileAttrPolicy policy = {true, true, false, false};
|
||||
EXPECT_FALSE(fake_super_restore_fd(fd, policy));
|
||||
|
||||
fake_super_store_fd(fd, 1001, 1002, 0751, 1700000000, 123456789);
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)(st.st_mode & 07777), 0751);
|
||||
@@ -379,7 +381,7 @@ static void test_fake_super_restore() {
|
||||
bits, and fake-super replay must not re-add them (a recorded 0666 restores
|
||||
as 0644, never as world-writable). */
|
||||
fake_super_store_fd(fd, 1001, 1002, 0666, 1700000000, 0);
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)(st.st_mode & 0777), 0644);
|
||||
|
||||
@@ -390,7 +392,7 @@ static void test_fake_super_restore() {
|
||||
EXPECT_EQ_INT((int)fsetxattr(wfd, FAKESUPER_XATTR, "not-a-valid-record", 19, 0), 0);
|
||||
close(wfd);
|
||||
}
|
||||
EXPECT_FALSE(fake_super_restore_fd(fd));
|
||||
EXPECT_FALSE(fake_super_restore_fd(fd, policy));
|
||||
fstat(fd, &st);
|
||||
EXPECT_EQ_INT((int)st.st_mtime, (int)before);
|
||||
|
||||
@@ -426,6 +428,7 @@ static void test_fake_super_owner_gate() {
|
||||
fake_super_store_fd(fd, 12345, 12346, 0755, 1700000000, 0);
|
||||
|
||||
Config* c = config_create();
|
||||
FileAttrPolicy policy = {true, true, false, false};
|
||||
EXPECT_NOT_NULL(c);
|
||||
|
||||
/* An explicit ownership policy is required before fake-super replay may
|
||||
@@ -435,7 +438,7 @@ static void test_fake_super_owner_gate() {
|
||||
/* --no-super: the owner leg is skipped even as root. */
|
||||
c->super_mode = SUPER_MODE_OFF;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||
struct stat st;
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||
@@ -444,7 +447,7 @@ static void test_fake_super_owner_gate() {
|
||||
/* AUTO with an identity policy: the recorded source owner is applied. */
|
||||
c->super_mode = SUPER_MODE_AUTO;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_uid, 12345);
|
||||
EXPECT_EQ_INT((int)st.st_gid, 12346);
|
||||
@@ -455,7 +458,7 @@ static void test_fake_super_owner_gate() {
|
||||
c->numeric_ids = false;
|
||||
c->super_mode = SUPER_MODE_ON;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||
EXPECT_EQ_INT((int)st.st_gid, 0);
|
||||
@@ -466,7 +469,7 @@ static void test_fake_super_owner_gate() {
|
||||
c->copy_as_uid = 777;
|
||||
c->copy_as_gid = 778;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||
EXPECT_EQ_INT((int)st.st_gid, 0);
|
||||
@@ -477,6 +480,63 @@ static void test_fake_super_owner_gate() {
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
/* MAJOR 1: the --fake-super owner replay must honor the per-side -o/-g split.
|
||||
* With only -o (preserve_owner) requested the recorded GROUP must be left
|
||||
* untouched, and with only -g (preserve_group) the recorded OWNER must be left
|
||||
* untouched. Root-gated: only root can observe a chown actually landing. */
|
||||
static void test_fake_super_owner_group_split() {
|
||||
if (geteuid() != 0)
|
||||
return; /* non-root cannot observe ownership changes; skip silently */
|
||||
const char* path = "test_fake_super_owner_group_split.txt";
|
||||
unlink(path);
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (fd < 0)
|
||||
return;
|
||||
bool has_xattr = setxattr(path, "user.fastsync.xprobe", "p", 1, 0) == 0;
|
||||
if (has_xattr)
|
||||
removexattr(path, "user.fastsync.xprobe");
|
||||
if (!has_xattr) {
|
||||
close(fd);
|
||||
unlink(path);
|
||||
return; /* filesystem without xattr support */
|
||||
}
|
||||
if (fchown(fd, 0, 0) != 0) {
|
||||
close(fd);
|
||||
unlink(path);
|
||||
return;
|
||||
}
|
||||
fake_super_store_fd(fd, 12345, 12346, 0755, 1700000000, 0);
|
||||
|
||||
Config* c = config_create();
|
||||
FileAttrPolicy policy = {true, true, false, false};
|
||||
EXPECT_NOT_NULL(c);
|
||||
struct stat st;
|
||||
|
||||
/* -o only: the owner is applied, the group stays at its current value (0). */
|
||||
c->preserve_owner = true;
|
||||
c->preserve_group = false;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_uid, 12345);
|
||||
EXPECT_EQ_INT((int)st.st_gid, 0);
|
||||
|
||||
/* -g only: the group is applied, the owner stays at its current value (0). */
|
||||
EXPECT_EQ_INT(fchown(fd, 0, 0), 0);
|
||||
c->preserve_owner = false;
|
||||
c->preserve_group = true;
|
||||
EXPECT_TRUE(identity_set_active(c));
|
||||
EXPECT_TRUE(fake_super_restore_fd(fd, policy));
|
||||
EXPECT_EQ_INT(fstat(fd, &st), 0);
|
||||
EXPECT_EQ_INT((int)st.st_uid, 0);
|
||||
EXPECT_EQ_INT((int)st.st_gid, 12346);
|
||||
|
||||
identity_clear_active();
|
||||
config_delete(c);
|
||||
close(fd);
|
||||
unlink(path);
|
||||
}
|
||||
|
||||
void test_xattr() {
|
||||
test_xattr_wire_roundtrip();
|
||||
test_xattr_reject_privileged_namespace();
|
||||
@@ -488,4 +548,5 @@ void test_xattr() {
|
||||
test_link_copy_fallback_preserves_xattrs();
|
||||
test_fake_super_restore();
|
||||
test_fake_super_owner_gate();
|
||||
test_fake_super_owner_group_split();
|
||||
}
|
||||
Reference in New Issue
Block a user