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
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user