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
+68
-3
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Module with backup functions.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -157,11 +158,13 @@ def _ensure_repo_id(backups_dir: str) -> str:
|
||||
return repo_id
|
||||
|
||||
|
||||
def _write_manifest(marker_path: str, backups_dir: str, snapshot_name: str):
|
||||
def _write_manifest(marker_path: str, backups_dir: str, snapshot_name: str,
|
||||
checksums: Optional[Dict[str, str]] = None):
|
||||
"""Write and fsync the completion manifest for a finished snapshot."""
|
||||
manifest = json.dumps({
|
||||
"repo_id": _ensure_repo_id(backups_dir),
|
||||
"snapshot": snapshot_name,
|
||||
"checksums": checksums or {},
|
||||
})
|
||||
_write_small_file_no_follow(marker_path, manifest)
|
||||
|
||||
@@ -192,6 +195,49 @@ def _read_manifest(marker_path: str) -> Union[dict, object, None]:
|
||||
return None
|
||||
|
||||
|
||||
def _sha256_file(path: str) -> str:
|
||||
"""sha256 hex digest of a file's current on-disk content."""
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def verify_snapshot(backup_entry: Union[os.DirEntry, fs.PseudoDirEntry]
|
||||
) -> List[str]:
|
||||
"""
|
||||
Verify a completed snapshot against the checksums recorded in its
|
||||
manifest at backup time. Only files freshly copied or rewritten
|
||||
during that run have a recorded checksum - files inherited unchanged
|
||||
via hardlink from an earlier snapshot are not re-hashed here, since
|
||||
they're covered by that earlier snapshot's own verification.
|
||||
|
||||
:return: relative paths whose current content no longer matches its
|
||||
recorded checksum, or that are missing. Empty means verified OK.
|
||||
:raises BackupFailedError: if the manifest itself is missing, legacy,
|
||||
or corrupt - there's nothing to verify against.
|
||||
"""
|
||||
manifest = _read_manifest(_get_backup_marker(backup_entry).path)
|
||||
if manifest is None or manifest is _LEGACY_MANIFEST:
|
||||
raise BackupFailedError(
|
||||
"Cannot verify %s: missing, corrupt, or pre-migration manifest"
|
||||
% backup_entry.name
|
||||
)
|
||||
|
||||
mismatches = []
|
||||
for relpath, expected_digest in manifest.get("checksums", {}).items():
|
||||
full_path = os.path.join(backup_entry.path, relpath)
|
||||
try:
|
||||
actual_digest = _sha256_file(full_path)
|
||||
except OSError:
|
||||
mismatches.append(relpath)
|
||||
continue
|
||||
if actual_digest != expected_digest:
|
||||
mismatches.append(relpath)
|
||||
return mismatches
|
||||
|
||||
|
||||
def _verify_safe_to_delete(entry: os.DirEntry, backups_dir_abs: str,
|
||||
repo_id: str) -> bool:
|
||||
"""
|
||||
@@ -752,6 +798,10 @@ def initiate_backup(sources,
|
||||
rsync_func = fs.rsync_ext if external_rsync else fs.rsync
|
||||
|
||||
backup_changed = False
|
||||
# sha256 digest of every freshly-copied-or-rewritten file this run,
|
||||
# keyed by its path relative to the snapshot root. Recorded in the
|
||||
# manifest so a completed snapshot's integrity can be verified later.
|
||||
checksums: Dict[str, str] = {}
|
||||
try:
|
||||
for src in sources:
|
||||
src_abs = os.path.abspath(src)
|
||||
@@ -766,11 +816,26 @@ def initiate_backup(sources,
|
||||
raise BackupFailedError(
|
||||
"Failed to copy %s: %s" % (entry_relpath, msg)
|
||||
)
|
||||
full_relpath = os.path.join(src_name, entry_relpath)
|
||||
if action in (fs.Actions.CREATE, fs.Actions.REWRITE):
|
||||
if msg:
|
||||
# fs.rsync() already hashed the file while
|
||||
# copying it.
|
||||
checksums[full_relpath] = msg
|
||||
else:
|
||||
# fs.rsync_ext() (external rsync) never returns a
|
||||
# digest - hash the copied file directly so
|
||||
# verify_snapshot() isn't silently a no-op when
|
||||
# external_rsync=True.
|
||||
full_path = os.path.join(staging.path, full_relpath)
|
||||
if (os.path.isfile(full_path)
|
||||
and not os.path.islink(full_path)):
|
||||
checksums[full_relpath] = _sha256_file(full_path)
|
||||
# TODO maybe should be run if first backup too?
|
||||
if latest_backup is not None:
|
||||
process_backed_entry(
|
||||
backup_dir=staging.path,
|
||||
entry_relpath=os.path.join(src_name, entry_relpath),
|
||||
entry_relpath=full_relpath,
|
||||
action=action,
|
||||
msg=msg,
|
||||
)
|
||||
@@ -804,7 +869,7 @@ def initiate_backup(sources,
|
||||
# then atomically rename the staging dir into its final snapshot name
|
||||
marker_name = "%s_%s" % (BACKUP_MARKER, snapshot_name)
|
||||
_write_manifest(os.path.join(staging.path, marker_name),
|
||||
backups_dir, snapshot_name)
|
||||
backups_dir, snapshot_name, checksums=checksums)
|
||||
_fsync_dir(staging.path)
|
||||
|
||||
final_path = os.path.join(backups_dir, snapshot_name)
|
||||
|
||||
Reference in new issue
Block a user