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:
5 files changed
+326
-46
No files matched your search
@@ -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) == []
|
||||
Reference in new issue
Block a user