fix(receiver): non-blocking receiver opens, inplace type gate, dry-run/B4/B5/B6

Address confirmed receiver security findings B1-B6:

B1 (HIGH): add O_NONBLOCK to the three receiver read-opens that opened an
existing destination/basis entry before the S_ISREG gate
(incremental_check_open_destination, basis_open_regular, hardlink_read_source)
so a client-planted FIFO can no longer block the receive thread forever while
the post-open type gate still rejects it.

B2 (HIGH/MED): --inplace now fstatat(AT_SYMLINK_NOFOLLOW)-probes the target and
refuses any existing non-regular entry, opens with O_NONBLOCK, and re-checks
S_ISREG on the opened fd.  This stops a FIFO from hanging the open and stops a
char/block device from being written directly (bypassing --write-devices).

B3 (MED): under --dry-run the incremental quick-skip no longer reads/hashes the
destination file for --checksum/--delta; it decides from metadata only and
reports would-transfer when the comparison is inconclusive, closing the
read-only-module content-hash oracle.

B4 (LOW): xattr_name_appliable() now gates the two system.posix_acl_* names on
preserve_acls (--acls), not the derived use_xattrs (--xattrs OR --acls).  The
receiver drops (never applies) ACL entries when -A was not negotiated while
keeping user.* working for -X.

B5 (INFO): receive_manifest_section() charges a per-entry overhead against
MAX_MANIFEST_BYTES and the aggregate entry count across all three sections is
capped at MAX_MANIFEST_ENTRIES.

B6 (MED): data_charge_session() reserves decompressed/chunk-copy bytes against
the owning ProtocolSession (MAX_CONNECTION_MEMORY) and records them on the Data
so data_destroy() releases them via the Data.owner path.  Applied to the
whole-file/append/delta decompression sites and chunk_deserialize() per-file
copies; a missing session owner degrades to the previous uncharged behavior.

Tests: FIFO destination/basis non-hang (with alarm), --inplace FIFO/device
refusal, dry-run no-read oracle test plus updated metadata-only dry-run tests,
ACL-without--acls drop, manifest total-entry cap, and chunk session charging.
This commit is contained in:
2026-09-14 16:19:26 +02:00
parent df887c73b1
commit a2370433b2
12 changed files with 635 additions and 63 deletions
+37 -6
View File
@@ -444,9 +444,10 @@ class TestRemoteDryRun:
self._seed(source)
clean_dir(dest)
# Populate the destination with a real transfer, then make exactly one
# file differ (content+size) and add a brand-new file.
result, _ = run_client(source, dest, port=shared_server.port)
# Populate the destination with a real transfer that preserves mtimes
# (--preserve), then make exactly one file differ (content+size) and add
# a brand-new file.
result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, f"seed transfer failed: {result.stderr[:200]}"
received = get_dest_received_dir(dest, source)
@@ -456,9 +457,11 @@ class TestRemoteDryRun:
f.write(b"newly added\n")
before = _snapshot_tree(received)
# --checksum makes the up-to-date decision content-based (the seed
# transfer did not preserve mtimes), so keep.txt/deep.txt report skip.
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum"],
# --checksum must NOT read destination contents in a dry-run (B3), so
# the up-to-date decision is metadata-only. The --preserve seed made
# keep.txt and deep.txt size+mtime-identical; the dry-run must also
# transmit metadata (--preserve) for that metadata to be comparable.
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum", "--preserve"],
port=shared_server.port)
assert result.returncode == 0, f"remote dry-run failed: {result.stderr[:300]}"
assert "Dry run:" in result.stdout, result.stdout[:200]
@@ -470,6 +473,34 @@ class TestRemoteDryRun:
assert "deep.txt" not in result.stdout, result.stdout
assert _snapshot_tree(received) == before, "remote dry-run mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_checksum_does_not_read_destination(self, shared_server):
"""B3: --dry-run --checksum against a read-only module must not read the
destination file's content (a 1-bit hash oracle). A same-size/same-content
file whose mtime differs is therefore reported as would-transfer because
the metadata-only decision is inconclusive, instead of being hashed and
silently skipped."""
source = os.path.join(TEST_DATA_DIR, "remote_dry_oracle_src")
dest = os.path.join(TEST_DATA_DIR, "remote_dry_oracle_dst")
self._seed(source)
clean_dir(dest)
result, _ = run_client(source, dest, flags=["--preserve"], port=shared_server.port)
assert result.returncode == 0, result.stderr[:200]
received = get_dest_received_dir(dest, source)
target = os.path.join(received, "keep.txt")
# Identical size and content, but a deliberately different mtime.
os.utime(target, (1000000000, 1000000000))
before = _snapshot_tree(received)
result, _ = run_client(source, dest, flags=["--dry-run", "--checksum", "--preserve"],
port=shared_server.port)
assert result.returncode == 0, result.stderr[:300]
assert "keep.txt" in result.stdout, (
f"dry-run --checksum must not read the destination to prove equality: {result.stdout}"
)
assert _snapshot_tree(received) == before, "dry-run mutated the destination"
@pytest.mark.ci
def test_remote_dry_run_into_empty_dest_creates_nothing(self, shared_server):
source = os.path.join(TEST_DATA_DIR, "remote_dry_empty_src")