Commit Graph
8 Commits
Author SHA1 Message Date
22757ece58 Make dry-run genuinely non-mutating (#23)
* Make dry-run genuinely non-mutating

fs.rsync() now takes a real dry_run code path: every mutating call
(copy, delete, chmod, chown, utime) is skipped, and a missing dst_dir
is never created - the same comparison logic still runs so the yielded
actions match what a real run would do.

backup.initiate_backup() diffs each source directly against the latest
completed snapshot on dry-run, instead of building and discarding a
throwaway staging directory with real hardlinks. cleanup_old_backups()
no longer creates the repo-id file as a side effect of a dry-run.

cli.py skips lock acquisition entirely on dry-run (set_backups_lock()
writes PID/timestamp metadata, itself a mutation) and exposes the
retention policy via --keep-all/--keep-daily/--keep-weekly/
--keep-monthly/--keep-yearly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Address Copilot review: guard restore lock release, reject negative retention values

- Guard restore --dry-run's finally block the same way backup's is
  guarded, for symmetry (release_backups_lock() was already a safe
  no-op when unheld, but this makes the dry-run contract explicit
  rather than relying on that implementation detail).
- Reject negative --keep-* values via a dedicated argparse type: a
  negative threshold shifts retention comparisons into the future and
  can make cleanup treat every existing backup as deletable.
- Add a restore --dry-run lock-skip regression test, mirroring the
  existing backup one.
- Fix _snapshot_all_stats() in the new dry-run non-mutation test to
  also record the backups_dir root's own stat, not just its children.

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>
2026-08-09 01:39:53 +00:00
4a4797e959 Document Python/external-rsync backend differences (#20)
* Document Python/external-rsync backend differences

- Note that mixing --external-rsync (which can recreate device/socket
  entries via --archive's -D) with the default Python hardlink-forward
  step is unsafe: _recursive_hardlink() only understands files,
  symlinks, and directories and errors on anything else.
- Note that --dry-run is I/O-free with --external-rsync but still
  fully copies into a throwaway staging directory with the Python
  backend, though neither leaves a lasting change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Clarify external-rsync dry-run description

Copilot review feedback on PR #20: rsync's --dry-run still reads and
stats source files to compute what would change, it just avoids
writing to the destination - "performs no I/O" overstated that.

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>
2026-08-06 17:20:47 +00:00
934482086f Document ACL, xattr, sparse-file, and hardlink backup behavior (#19)
* Document ACL, xattr, sparse-file, and hardlink backup behavior

Extend the README Limitations section with the filesystem-feature
behaviors that weren't documented: ACLs/xattrs/flags are never
preserved by either backend, sparse files are materialized as full
allocated copies, source-side hardlinks are not preserved as
hardlinks in the backup, and ownership is only preserved when
running as root.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Soften README timestamp/ownership claims for external-rsync backend

Nanosecond mtime precision and 'silently logged chown failures' are
Python-backend specifics that don't hold for --external-rsync: rsync's
own timestamp precision depends on the build/filesystem, and it skips
disallowed ownership changes itself rather than going through our
logging path.

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>
2026-08-06 16:00:09 +00:00
snegovandMaxim Shulga 33dcbe6c58 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>
2026-08-04 15:58:34 +00:00
snegovandMaxim Shulga 248204fe41 Add immediate safety warning to README (#12)
* Add immediate safety warning to README

- Remove the production-use claim from Project Status.
- Mark the project experimental until the P0 data-safety checklist
  passes, with a prominent warning banner up top.
- Add a Limitations section: warn against modifying hardlinked
  snapshots in place, document the lack of a restore command, and
  note special-entry exclusion and symlink-preservation behavior.
- Check off the corresponding IMPROVEMENTS.md acceptance criteria.

* Qualify special-entry exclusion limitation to the Python implementation

--external-rsync uses rsync's --archive flag, which copies/recreates
FIFOs, sockets, and device nodes instead of excluding them - the
exclusion behavior only applies to the default Python implementation.

* Qualify device-node recreation reliability under --external-rsync

rsync -a implies -D (devices/specials), but creating device nodes
typically requires root on the destination - without it rsync may
skip or fail on them rather than reliably recreating them.

---------

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
2026-08-04 04:49:00 +00:00
snegov 96f063d877 Update Python version requirement to 3.8 or higher 2026-02-06 04:51:30 +00:00
Claude 6839d21a77 Add comprehensive README documentation
- Project overview and features
- Installation and usage instructions
- Detailed explanation of how hardlink-based backups work
- Command-line options and examples
- Backup structure and retention policy details
- Development and testing information
2025-11-09 20:23:56 -08:00
snegov 84546243cb Add setup.py 2021-11-13 07:39:23 +03:00