Prevent stale, truncated, or changing-file copies (#10)

* Prevent stale, truncated, or changing-file copies

- copy_file() now loops until every byte is actually written (os.write
  may write fewer bytes than given) and compares fstat() on the
  source's own open descriptor before/after the read loop - this
  reflects the underlying inode directly, catching a source file
  truncated, grown, or overwritten in place while being read, and isn't
  fooled by the source path being renamed away mid-copy. Raises
  BackupCreationError (already a fatal, staging-discarding condition in
  initiate_backup) on a mismatch.
- copy_file() returns a sha256 hex digest of the copied content;
  copy_direntry()/update_direntry() propagate it; rsync() carries it
  through the previously-unused msg field for CREATE/REWRITE actions.
- initiate_backup() collects those digests into a checksums dict keyed
  by path relative to the snapshot root, recorded in the completion
  manifest (already JSON since the topology-validation work).
- Added verify_snapshot(): re-hashes every file with a recorded
  checksum and reports any mismatch, satisfying the release gate's
  "completed snapshot passes manifest verification" requirement.
- Switched every mtime comparison and os.utime() restore (rsync's
  file/directory sync, hardlink_dir's directory metadata, the
  hardlink-breaking copy-on-write step) from float seconds to
  nanoseconds, so a same-second modification can't be missed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Address Copilot review: hash checksums for external rsync too, fix log/error text

- initiate_backup() now hashes CREATE/REWRITE files itself when msg is
  empty (fs.rsync_ext(), the external-rsync path, never yields a
  digest). Without this, verify_snapshot() silently became a no-op for
  every snapshot created with external_rsync=True.
- Fixed a pre-existing log message that said "different inodes" for
  the branch whose actual condition is src/dst sharing an inode.
- verify_snapshot()'s error message now also mentions a corrupt
  manifest, not just missing/pre-migration, matching what
  _read_manifest() actually distinguishes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-08-04 00:10:25 +00:00
1 parent cc61aed10c
commit b4db7c806b
5 files changed
+326 -46

No files matched your search

+60
View File
@@ -164,6 +164,66 @@ class TestCopyFile:
dst_stat = os.stat(dst_path)
assert src_stat.st_mode == dst_stat.st_mode
def test_returns_sha256_of_content(self, tmp_path):
"""copy_file returns the sha256 hex digest of the copied bytes"""
import hashlib
src_path = os.path.join(str(tmp_path), "source.txt")
dst_path = os.path.join(str(tmp_path), "dest.txt")
content = b"some content to hash"
with open(src_path, "wb") as f:
f.write(content)
digest = fs.copy_file(src_path, dst_path)
assert digest == hashlib.sha256(content).hexdigest()
def test_detects_source_truncated_during_copy(self, tmp_path):
"""
A source file whose size changes between the read loop starting
and finishing (detected via fstat on the same descriptor) must
raise, not silently produce a truncated/inconsistent copy.
"""
from unittest import mock
src_path = os.path.join(str(tmp_path), "source.txt")
dst_path = os.path.join(str(tmp_path), "dest.txt")
with open(src_path, "wb") as f:
f.write(b"x" * 1000)
real_read = os.read
call_count = {"n": 0}
def read_then_truncate(fd, n):
call_count["n"] += 1
data = real_read(fd, n)
if call_count["n"] == 1:
os.truncate(src_path, 10)
return data
with mock.patch("os.read", side_effect=read_then_truncate):
with pytest.raises(fs.BackupCreationError):
fs.copy_file(src_path, dst_path)
def test_partial_write_is_completed(self, tmp_path):
"""os.write() may write fewer bytes than given; copy_file must
loop until everything requested has actually been written."""
from unittest import mock
src_path = os.path.join(str(tmp_path), "source.txt")
dst_path = os.path.join(str(tmp_path), "dest.txt")
content = b"y" * 5000
with open(src_path, "wb") as f:
f.write(content)
real_write = os.write
with mock.patch("os.write",
side_effect=lambda fd, data: real_write(
fd, bytes(data)[:1])):
fs.copy_file(src_path, dst_path)
with open(dst_path, "rb") as f:
assert f.read() == content
class TestCopyDirEntry:
"""Test suite for copy_direntry function."""
+108
View File
@@ -973,3 +973,111 @@ class TestErrorRecovery:
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert len(backups) == 1
def test_completed_snapshot_passes_manifest_verification(integration_dirs):
"""A completed snapshot's checksums must verify against its content."""
backups_dir, source_dir = integration_dirs
(source_dir / "file1.txt").write_text("content1")
(source_dir / "file2.txt").write_text("content2")
snapshot = bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False,
)
assert bk.verify_snapshot(snapshot) == []
def test_verify_snapshot_detects_corruption(integration_dirs):
"""Corrupting a file after backup must be caught by verify_snapshot."""
backups_dir, source_dir = integration_dirs
(source_dir / "file1.txt").write_text("content1")
snapshot = bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False,
)
source_name = os.path.basename(str(source_dir))
backed_up_file = os.path.join(
snapshot.path, source_name, "file1.txt"
)
with open(backed_up_file, "w") as f:
f.write("corrupted")
mismatches = bk.verify_snapshot(snapshot)
assert os.path.join(source_name, "file1.txt") in mismatches
def test_source_changed_during_copy_fails_backup(integration_dirs,
monkeypatch):
"""
A source file that changes size while being copied must fail the
whole backup rather than produce an inconsistent snapshot.
"""
backups_dir, source_dir = integration_dirs
src_file = source_dir / "changing.txt"
src_file.write_text("x" * 1000)
real_read = os.read
call_count = {"n": 0}
def read_then_truncate(fd, n):
call_count["n"] += 1
data = real_read(fd, n)
if call_count["n"] == 1:
os.truncate(str(src_file), 10)
return data
monkeypatch.setattr(os, "read", read_then_truncate)
with pytest.raises(bk.BackupFailedError):
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False,
)
# no completed snapshot must exist after the failure
backups = [b for b in os.listdir(str(backups_dir))
if not b.startswith(".")]
assert backups == []
@pytest.mark.skipif(not RSYNC_AVAILABLE, reason="rsync not available")
def test_external_rsync_snapshot_still_gets_checksums(integration_dirs):
"""
fs.rsync_ext() (external rsync) never yields a digest in msg, unlike
fs.rsync() - initiate_backup() must hash the copied files itself in
that case, or verify_snapshot() silently becomes a no-op whenever
--external-rsync is used.
"""
backups_dir, source_dir = integration_dirs
(source_dir / "file1.txt").write_text("content1")
# Establish baseline with Python rsync first, same as
# test_external_rsync_creates_backup: a from-scratch external-rsync
# run emits a "created directory ..." header line this codebase's
# itemize-output parser doesn't handle, which is an unrelated,
# pre-existing limitation.
bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False,
)
time.sleep(1.1)
(source_dir / "file2.txt").write_text("content2")
snapshot = bk.initiate_backup(
sources=[str(source_dir)],
backups_dir=str(backups_dir),
dry_run=False,
external_rsync=True,
)
manifest = bk._read_manifest(bk._get_backup_marker(snapshot).path)
assert manifest["checksums"]
assert bk.verify_snapshot(snapshot) == []