feat(p7-output-fs): sparse hole preservation (-S), partial retention (-P), fake-super replay; block-size verified; crtimes/stderr -> Impossible/Divergence (Wave B)

- write_all_sparse: skips all-zero runs >= 4096 bytes via lseek(SEEK_CUR) and
  ftruncates the final size, wired into the atomic temp+rename and --inplace
  paths with no wire change (full image already in memory).
- --partial retention: on a save failure after the temp held data, rename the
  already-written temp to the destination path (best-effort; falls through to
  unlink; never retains when --partial is off) so --append/--append-verify can
  resume; tested by forcing futimens EINVAL with an out-of-range nsec.
- --block-size aliases --delta-block; verified config->delta_block_size is
  honored by the delta engine end-to-end (unit + integration tests).
- fake_super_restore_fd: parses and re-applies user.fastsync.stat fd-relative
  (fchown best-effort/non-root skipped, fchmod, futimens); a save under
  --fake-super now both records and re-applies.
- -N/--crtimes and --stderr=client promoted to a new 'Impossible/Divergence'
  status bucket (Summary: 136/2/4/3/2 = 147).
- cppcheck/clang-format clean; unit 37/37; integration 407 passed.
This commit is contained in:
2026-09-11 15:58:20 +02:00
parent f34eb34f87
commit 47de05d215
14 changed files with 549 additions and 56 deletions
+112
View File
@@ -4255,6 +4255,118 @@ class TestCrtimes:
f"combined -U -N dest atime {dst_st.st_atime} != {atime}"
class TestSparse:
"""-S/--sparse: the receiver preserves holes by skipping long zero runs with
lseek (no wire change; the full image is in memory). The destination file
must round-trip its logical size and content byte-for-byte; on filesystems
that report holes (SEEK_HOLE/SEEK_DATA) we additionally assert the file is
genuinely sparse via st_blocks, but that check is tolerant (CI filesystems
may report no holes)."""
def _make_sparse_source(self, name, total, zero_start, zero_len):
source = os.path.join(TEST_DATA_DIR, name)
clean_dir(source)
sfile = os.path.join(source, "blob.bin")
with open(sfile, "wb") as f:
head = os.urandom(zero_start)
tail = os.urandom(total - zero_start - zero_len)
f.write(head)
f.write(b"\x00" * zero_len)
f.write(tail)
assert f.tell() == total
return source, sfile
@pytest.mark.parametrize("flag", ["-S", "--sparse"])
@pytest.mark.parametrize("mt", [False, True])
def test_sparse_transfer_round_trips(self, shared_server, flag, mt):
total = 4 * 1024 * 1024
source = os.path.join(TEST_DATA_DIR, f"sparse_mt{mt}_{flag.lstrip('-')}_src")
dest = os.path.join(TEST_DATA_DIR, f"sparse_mt{mt}_{flag.lstrip('-')}_dst")
clean_dir(source)
clean_dir(dest)
zero_start = 1 * 1024 * 1024
zero_len = 2 * 1024 * 1024
_, sfile = self._make_sparse_source(os.path.basename(source), total, zero_start, zero_len)
with open(sfile, "rb") as f:
src_bytes = f.read()
flags = [flag] + (["--threads"] if mt else [])
result, _ = run_client(source, dest, flags=flags, port=shared_server.port)
assert result.returncode == 0, \
f"{flag} transfer failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
dfile = os.path.join(received, "blob.bin")
assert os.path.getsize(dfile) == total, "logical size must match data_size"
with open(dfile, "rb") as f:
assert f.read() == src_bytes, "sparse destination content must round-trip exactly"
# Tolerant sparseness assert: if the filesystem reports holes, the file
# must actually be sparse (fewer allocated blocks than its size).
with open(dfile, "rb") as f:
off = os.lseek(f.fileno(), zero_start, os.SEEK_DATA)
if off >= 0:
hole = os.lseek(f.fileno(), off, os.SEEK_HOLE)
else:
hole = -1
if hole > zero_start:
st = os.stat(dfile)
assert st.st_blocks * 512 < total, \
f"-S file not sparse: {st.st_blocks} blocks for {total} bytes"
def test_sparse_inplace(self, shared_server):
"""--sparse must also preserve holes in the --inplace write path."""
total = 2 * 1024 * 1024
source = os.path.join(TEST_DATA_DIR, "sparse_inplace_src")
dest = os.path.join(TEST_DATA_DIR, "sparse_inplace_dst")
clean_dir(source)
clean_dir(dest)
_, sfile = self._make_sparse_source(os.path.basename(source), total, total // 2,
total // 4)
with open(sfile, "rb") as f:
src_bytes = f.read()
result, _ = run_client(source, dest, flags=["-S", "--inplace"], port=shared_server.port)
assert result.returncode == 0, \
f"-S --inplace failed: {(result.stderr or result.stdout)[:300]}"
received = get_dest_received_dir(dest, source)
dfile = os.path.join(received, "blob.bin")
assert os.path.getsize(dfile) == total
with open(dfile, "rb") as f:
assert f.read() == src_bytes
class TestBlockSize:
"""--block-size / --delta-block: the checksum block size is genuinely honored
by the delta engine (both spellings parse to config->delta_block_size). An
end-to-end delta transfer with a non-default block size must still be
byte-exact."""
@pytest.mark.parametrize("flag", ["--block-size", "--delta-block"])
def test_non_default_block_size_delta_transfer(self, shared_server, flag):
source = os.path.join(TEST_DATA_DIR, "blocksize_delta_src")
dest = os.path.join(TEST_DATA_DIR, "blocksize_delta_dst")
clean_dir(source)
clean_dir(dest)
payload = os.urandom(300 * 1024) # enough for several 1 KiB blocks
with open(os.path.join(source, "big.bin"), "wb") as f:
f.write(payload)
# First run installs the file; second run with delta + a small block size.
result, _ = run_client(source, dest, flags=["-S"],
port=shared_server.port)
assert result.returncode == 0
received = get_dest_received_dir(dest, source)
# Change the source, then delta-transfer with a non-default block size.
with open(os.path.join(source, "big.bin"), "ab") as f:
f.write(os.urandom(4096))
clean_dir(dest)
result, _ = run_client(source, dest,
flags=["--incremental", "--delta", flag, "1024"],
port=shared_server.port)
assert result.returncode == 0, \
f"{flag} 1024 delta transfer failed: {(result.stderr or result.stdout)[:300]}"
with open(os.path.join(received, "big.bin"), "rb") as f:
assert f.read() == open(os.path.join(source, "big.bin"), "rb").read()
class TestOmitTimes:
"""-O/--omit-dir-times and -J/--omit-link-times are recognized and cross the
wire as receiver-side preferences. FastSync does not currently apply dir or