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

+56 -4
View File
@@ -37,6 +37,11 @@ pip install .
## Usage
cura-te-ipsum has two subcommands, `backup` and `restore`. For backwards
compatibility, `backup` is implied when the first argument isn't a
recognized subcommand - so `cura-te-ipsum -b DIR SOURCE` and
`cura-te-ipsum backup -b DIR SOURCE` are equivalent.
### Basic Backup
```bash
@@ -85,6 +90,53 @@ cura-te-ipsum -b /backups /home/user/data --verbose
cura-te-ipsum -b /backups /home/user/data --external-rsync
```
### Restoring a Backup
```
cura-te-ipsum restore -b BACKUPS_DIR DEST [PATH ...]
Required Arguments:
-b BACKUPS_DIR Directory where backups are stored
DEST Directory to restore into (created if missing)
Optional Arguments:
PATH Restore only these snapshot-relative paths - each
starts with the source directory's basename (e.g.
"data/documents/notes.txt" for a source backed up
from /home/user/data); default is everything
--snapshot NAME Snapshot to restore, e.g. 20260101_120000
(default: the latest snapshot)
--overwrite {never,always}
What to do when a destination path already exists
(default: never - existing paths are left alone
and reported as skipped)
--verify Verify restored content against the snapshot's
manifest checksums afterwards
-n, --dry-run Show what would be restored without copying
anything or creating DEST
-f, --force Wait for a running backup/restore instead of
exiting immediately
-v, --verbose Enable debug logging
```
Restoring the latest snapshot in full:
```bash
cura-te-ipsum restore -b /backups /tmp/recovered
```
Restoring one file from a specific snapshot, verifying it afterwards
(here the source was backed up from `/home/user/data`, so the path
starts with `data/`):
```bash
cura-te-ipsum restore -b /backups --snapshot 20260101_120000 --verify \
/tmp/recovered data/documents/notes.txt
```
A restore takes the same `.backups_lock` a backup does, so retention
cleanup can never delete a snapshot out from under a restore in progress.
Restored files keep the source's mode, ownership (when running as root),
and timestamps; symlinks are recreated as symlinks, never followed.
## How It Works
### Hardlink-Based Snapshots
@@ -139,10 +191,10 @@ backups/
truncating a file in one snapshot changes that same file's content
in every other snapshot sharing its inode. Treat every snapshot
directory as read-only; copy files out before editing them.
- **No dedicated restore command.** Recovering data means manually
copying files out of a snapshot directory - there is no restore
workflow with dry-run, partial restore, or overwrite-policy support
yet.
- **Restore preserves data content and standard metadata only.** The
`restore` command copies file content, mode, ownership, timestamps,
and symlinks; it does not preserve ACLs, extended attributes, or
filesystem-specific flags.
- **Special filesystem entries (FIFOs, sockets, device nodes) are
excluded, not backed up** - but only with the default Python
implementation, which records them in the snapshot manifest as