Implement a restore command (#13)

* Implement restore command

Add a restore subcommand alongside the existing backup command:
- curateipsum/restore.py: restore_snapshot() and verify_restored(),
  reusing fs.scantree/copy_direntry/rm_direntry so metadata handling
  (mode, ownership, timestamps, symlinks) matches backup exactly.
- backup.find_backup(): resolve a snapshot by name or latest.
- cli.py: argparse subcommands (backup/restore), with backup implied
  when no subcommand is given so existing invocations keep working.

Restore supports dry-run (creates nothing), partial restore by
snapshot-relative path, an overwrite policy (never/always), a
destination-inside-snapshot boundary check, and manifest-checksum
verification of restored content. It takes the same backups_lock a
backup does, since retention could otherwise delete the snapshot
being restored out from under it.

Closes P1 'Provide a safe restoration workflow' in IMPROVEMENTS.md.

* Address Copilot review: symlink-escape boundary check, docstring fix

- restore_snapshot() and verify_restored() now canonicalize each
  destination path and refuse to operate on it if it resolves outside
  dest_dir - a symlink planted under dest_dir (by another process, or
  left over from a prior run) could otherwise redirect a write, delete,
  or read outside the intended destination.
- Fixed restore_snapshot()'s docstring: the skipped-paths list reports
  what would be skipped on dry-run too, it isn't always empty.

* Address Copilot suppressed findings: stream plan_restore, restore-specific lock message

- plan_restore() is now a generator instead of building a full list in
  memory, matching fs.scantree()'s streaming style everywhere else in
  the codebase.
- restore CLI logs a restore-specific message on lock-acquire failure,
  since set_backups_lock()'s own log line talks about a 'previous
  backup', which read wrong for a restore.

* Fix PATH docs: snapshot-relative means source-basename-prefixed

README and CLI help examples for restore's PATH argument omitted the
source directory's basename, which backup.initiate_backup() always
prefixes onto snapshot-relative paths - the given examples would have
matched nothing in an actual snapshot.

---------

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
This commit is contained in:
snegovandMaxim Shulga authored and GitHub committed 2026-08-04 15:58:34 +00:00
1 parent 248204fe41
commit 33dcbe6c58
7 files changed
+799 -41

No files matched your search

+23
View File
@@ -338,6 +338,29 @@ def _date_from_backup(backup_entry: os.DirEntry) -> datetime:
return datetime.strptime(backup_entry.name, BACKUP_ENT_FMT)
def find_backup(backups_dir: str,
snapshot_name: Optional[str] = None) -> os.DirEntry:
"""
Return a specific snapshot by name, or the latest one if
snapshot_name is None.
:raises BackupFailedError: if snapshot_name doesn't match any backup
in backups_dir, or no backups exist at all.
"""
if snapshot_name is None:
latest = _get_latest_backup(backups_dir)
if latest is None:
raise BackupFailedError("No backups found in %s" % backups_dir)
return latest
for entry in _iterate_backups(backups_dir):
if entry.name == snapshot_name:
return entry
raise BackupFailedError(
"No such backup %r in %s" % (snapshot_name, backups_dir)
)
# Open lock file descriptors currently held by this process, keyed by the
# absolute path of the backups directory they lock. The kernel associates
# an flock() with this exact open file description, so the descriptor must