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:
2026-09-15 19:32:02 +02:00
parent b3f7cad4db
commit 34970b961c
35 changed files with 2523 additions and 485 deletions
+1 -1
View File
@@ -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
+10 -3
View File
@@ -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:
+4 -3
View File
@@ -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"
+421
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}