Commit Graph
91 Commits
Author SHA1 Message Date
snegov bfcb81e90b Add CLAUDE.md 2026-10-11 13:26:08 -07:00
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
699c569cc2 Test nanosecond mtime precision on file and symlink copies (#22)
* Test nanosecond mtime precision on file and symlink copies

Existing preserves-times tests only set whole-second timestamps via
os.utime(path, (sec, sec)), so a backend that silently truncated to
second resolution would still pass them. The Python backend's
nanosecond-precision guarantee (documented in README's Limitations
section) was otherwise untested at sub-second granularity.

Combined with CI already running the full suite on both ubuntu-latest
and macos-latest, this closes the "Test supported behavior on Linux
and macOS" item in IMPROVEMENTS.md.

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

* Rename nanosecond timestamp tests to cover atime too

Copilot review feedback on PR #22: both tests assert st_atime_ns and
st_mtime_ns, but the names/docstrings said "mtime" only.

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

* Assert symlink entry is found before use in nanosecond test

Copilot review feedback on PR #22: if the scandir() loop didn't find
the symlink, entry stayed None and copy_direntry() would fail with a
less-informative error than an explicit assertion.

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:12:50 +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
87cdb5cadb Reduce CI cost: dedupe triggers, thin macOS matrix (#21)
- Drop push-triggered runs for non-master branches; pull_request
  already covers every PR commit, so push+pull_request was running
  the full 14-job matrix twice per push (28 jobs).
- Only run the full Python 3.8-3.14 matrix on ubuntu-latest. macOS
  runners bill at 10x Linux minutes; test just the oldest and newest
  supported Python there to catch platform-specific issues.
- Net effect: 28 jobs per PR push -> 9 (7 Linux + 2 macOS).

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 16:37:57 +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
91b7bb133c Add an explicit one-filesystem policy to backups (#18)
* Add an explicit one-filesystem policy to backups

scantree() gains an opt-in one_filesystem flag: when a subdirectory's
device differs from the walk root's (a mount point), its contents are
excluded from the walk. The mount-point directory itself is still
yielded, so it appears as an empty directory in the backup rather than
vanishing silently - matching rsync's --one-file-system behavior.

Wired into rsync()'s two source-tree walks so backups never silently
pull in content from a bind mount or other filesystem mounted under
the source tree.

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

* Fix one-filesystem policy: symlinked root and external rsync parity

- scantree() computed the walk root's device with os.lstat(), but
  scandir() follows the root path if it's a symlink. A symlinked root
  would compute the symlink's own device instead of the target
  directory's, making every real subdirectory look cross-device and
  get its contents wrongly excluded. Use os.stat() for the root only.
- rsync_ext() (the external-rsync backend) didn't pass
  --one-file-system, so backups using external_rsync=True still
  crossed mount points despite the pure-Python path now refusing to.

Addresses GitHub Copilot review feedback on PR #18.

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 03:38:16 +00:00
e3c8acf954 Fix test that leaks a chdir'd cwd across the test run (#17)
test_relative_symlink_to_common_file changed the process working
directory via os.chdir() but never restored it, leaving subsequent
tests running from a tmp_path fixture directory that gets deleted
after the test. Restore cwd in a finally block, matching the pattern
already used in test_pseudo_direntry.py.

Also checks off the now-satisfied xfail-marker item: no xfail markers
remain in the repo.

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 02:17:40 +00:00
7618ef7ffb Add macOS to CI test matrix (#16)
Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 02:07:43 +00:00
d696f2aebb Run CI on pull requests as well as pushes (#15)
Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 23:02:44 +00:00
4461fe2359 Fix Unix-socket test failing on macOS due to AF_UNIX path length (#14)
tmp_path nests too deeply for AF_UNIX socket paths (~104-108 byte
limit on macOS). Use a flat tempfile.TemporaryDirectory(), matching
the pattern already used by TestEntryKind::test_socket.

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 22:52:42 +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
a29517e2fc Handle special filesystem entries explicitly (#11)
* Handle special filesystem entries explicitly

- Added fs.entry_kind(): classifies via lstat() mode bits alone
  ("file", "dir", "symlink", "fifo", "socket", "block_device",
  "char_device", "unknown") - never opens the entry, so classifying a
  FIFO can't block even without a writer.
- rsync() now classifies every source entry before touching it and
  yields a new Actions.EXCLUDE for anything outside file/dir/symlink,
  instead of falling through to copy_direntry()'s file branch, which
  would either block forever opening a writerless FIFO for read, or
  raise an OSError that initiate_backup() treats as fatal (aborting the
  whole backup for one socket).
- initiate_backup() records exclusions in the completion manifest
  (keyed by snapshot-relative path, valued with the excluded kind), and
  no longer nest_hardlinks or counts a recurring exclusion as a change
  worth keeping a new snapshot for.
- Chose the "recorded exclusion" policy the plan allows, rather than
  fail-fast: a FIFO or socket living in a backed-up tree is not itself
  a backup failure, and previously turned into one.

Note: rsync_ext() (external rsync) is unaffected by this change and
keeps whatever its own --archive flags do with special files; the P1
item on Python/external implementation parity covers reconciling that.
The pre-existing test_src_is_socket failure (AF_UNIX path length on
macOS tmp_path) is untouched here - it's separately tracked under P1
test fixes - but this PR adds working socket/FIFO/char-device coverage
via a short-path tempdir.

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

* Address Copilot review: fix stale exclude, exclusion-only diffs, test portability

- fs.rsync(): remove the existing dst_entry before yielding EXCLUDE for
  a src entry with an unsupported kind, so an entry inherited via
  hardlink from a prior snapshot doesn't linger in the new one.
- backup.initiate_backup(): compare this run's excluded set against
  the previous snapshot's manifest; a change in exclusions alone now
  counts as backup_changed, so the updated manifest gets written.
- tests: skip mkfifo/AF_UNIX-dependent tests on platforms without
  support, and use TemporaryDirectory/context managers instead of
  manual mkdtemp/unlink for more robust cleanup.

* Remove unused shutil import from test_rsync.py

Left over after switching the socket/fifo tests to
tempfile.TemporaryDirectory(), per Copilot's suppressed comment.

* Fix rm_direntry() no-op on special dst entries

rm_direntry() only handled file/symlink/dir - a FIFO, socket, or
device node at dst (e.g. inherited from an external-rsync snapshot)
silently survived removal, since is_file/is_dir/is_symlink all
return False for it. Any non-directory entry is unlinkable the same
way, so invert the branching: recurse for directories, unlink
everything else.

---------

Co-authored-by: Maxim Shulga <maxim.shulga@alludo.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 03:25:37 +00:00
b4db7c806b 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>
2026-08-04 00:10:25 +00:00
cc61aed10c Preserve symlinks and old-snapshot immutability (#9)
* Preserve symlinks and old-snapshot immutability

Two real bugs found while implementing this item, both in fs.py:

- copy_direntry() checked entry.is_dir() (default follow_symlinks=True)
  before entry.is_symlink(). A symlink pointing at a directory was
  misclassified as a directory and an empty real directory was created
  in its place, silently destroying the symlink and losing its target
  entirely. Fixed by checking is_symlink() first, and is_dir() with
  follow_symlinks=False.
- rsync()'s permission/ownership-sync branch called os.chmod/os.chown
  on dst_entry.path unconditionally. Two problems: (1) for a symlink
  whose target already matched, this had no follow_symlinks=False
  guard and could rewrite an external symlink target's permissions;
  (2) for a regular file, that path may still be hardlinked to the
  same file in the previous, completed snapshot (hardlink_dir() links
  unchanged files in at the start of every backup) - mutating it in
  place silently corrupted every older snapshot sharing the inode.
  Fixed by skipping symlinks entirely (nothing to sync once their
  target matches) and adding _break_shared_hardlink(), a copy-on-write
  step that gives a file its own inode before any in-place metadata
  change.

Also fixed the dir-mtime-restore loop's is_dir() call (same
default-follow_symlinks issue, lower severity since its own operations
were already lstat-based/no-follow).

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

* Address Copilot review: preserve ownership/timestamps when breaking hardlinks, fix test fd leak

- _break_shared_hardlink() now explicitly restores the original file's
  uid/gid and atime/mtime on the new private copy before it replaces
  the shared one. copy_file() only carries over mode at create time, so
  without this a caller that only meant to update permissions would
  silently also reset ownership/mtime as a side effect of the
  copy-on-write break itself. Also cleans up the temp file on failure
  and uses a less collision-prone temp name.
- Fixed a file descriptor leak in the new hardlink-immutability test
  (bare open(...).read() without a context manager)
- Strengthened the same test to assert mtime survives the break

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-03 23:08:24 +00:00
fd613edad0 Correct retention before enabling automatic deletion (#8)
* Correct retention before enabling automatic deletion

- Compute each retention tier's kept set independently from its own
  name range and union them, instead of carrying a single 'previous
  backup' comparison across tier boundaries - that carry-over was the
  actual cause of bug #32 (a coarser tier could evict a backup a finer
  tier, or the always-keep-latest rule, had already decided to retain)
- Group weekly retention by (ISO year, ISO week) instead of ISO week
  number alone, fixing bug #33 (same week number in different years,
  or late-December dates that belong to next year's ISO week 1, were
  treated as duplicates/collisions)
- Validate the computed retention plan (latest snapshot present, no
  phantom entries) before deleting anything
- Fix cli.py to run initiate_backup before cleanup_old_backups, and
  skip cleanup entirely if the backup raised - retention must only run
  once a new snapshot is durable and complete, and a failed backup must
  never trigger deletion of history
- Quarantine (rename) a deletion candidate before recursively removing
  it, reusing the already-hardened stale-staging-dir cleanup to finish
  the job if the process crashes mid-delete
- Remove both retention xfail markers (bugs #32, #33) now that the
  tests they guarded pass; update pre-existing tests whose expectations
  encoded the old bug (deleting the latest snapshot when it collided
  with the next-newest one in a coarser bucket)

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

* Address Copilot review: replace asserts with runtime checks, don't swallow quarantine-delete failures

- Retention-plan validation now raises BackupFailedError instead of
  using assert, which python -O would silently strip ahead of a
  destructive operation
- _quarantine_and_delete no longer passes ignore_errors=True to
  rmtree; a removal failure is now logged with the quarantine path so
  operators can find and retry it, instead of silently leaving a
  hidden directory consuming space

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-03 22:47:43 +00:00
72a8fe2b59 Validate topology and destructive path boundaries (#7)
* Validate topology and destructive path boundaries

- Canonicalize sources/backups_dir and reject overlap in either direction
- Reject duplicate sources and destination basename collisions
- Reject non-normalized relative paths in nest_hardlink
- Give each backup repository a persistent identity, recorded in every
  snapshot's completion manifest
- Verify repo identity, directory type (no symlinks), containment, and
  device before any recursive snapshot/staging-dir deletion, refusing to
  cross mount points

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

* Address Copilot review: root-path check, symlink/TOCTOU-safe writes, legacy marker adoption

- _is_same_or_within: drop the redundant, root-mishandling startswith
  check; commonpath() alone is already component-aware
- _ensure_repo_id/_write_manifest: write via temp-file + atomic rename
  (O_NOFOLLOW, O_EXCL on the temp file) instead of open+write in place,
  so writes can never go through a pre-existing symlink and are never
  left half-written
- _verify_safe_to_delete: distinguish a legacy empty marker (pre-dates
  the manifest format, can only be a pre-migration artifact) from a
  non-empty-but-invalid one (corrupt or forged); adopt the former into
  the repository instead of refusing to delete it forever

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-03 22:26:51 +00:00
a987180653 Replace PID-file process killing with a real fcntl.flock() lock (#6)
* Replace PID-file process killing with a real fcntl.flock() lock

set_backups_lock()/release_backups_lock() now use an exclusive
fcntl.flock() on the lock file's inode instead of trusting the PID
written inside it. The kernel releases the lock automatically on
normal exit, on any signal, and on a crash - no PID liveness check or
staleness heuristic is needed, and there is nothing left in this code
path that sends a signal to another process.

- The open file descriptor is held for the full operation in a
  module-level _held_locks map keyed by the backups directory, and
  release_backups_lock() only ever releases a descriptor this process
  itself acquired, never one it infers from the lock file's content.
- PID and start time are still written into the lock file, but purely
  as diagnostics for whoever reads it (e.g. in a warning log line);
  they play no role in acquisition or release decisions anymore.
- --force no longer sends SIGTERM/SIGKILL to the previous holder. It
  now blocks on the same flock() until that process releases the lock
  on its own (exit, signal, or crash all count).
- release_backups_lock() intentionally stops unlinking the lock file:
  since flock() locks an inode rather than a path, unlinking while
  another process is blocked opening the same path would let that
  process (or a third one) acquire an independent lock on a fresh
  inode at the same path, defeating mutual exclusion.

Rewrites TestBackupLock to match: a real second file descriptor with
its own flock() stands in for "another process" in the fast unit
tests, and a new subprocess-based test proves end-to-end that only one
process can hold the lock and that killing the holder with SIGKILL
frees it for the next acquirer without our code ever calling os.kill.

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

* Address Copilot review feedback on flock-based lock

- Make the fcntl import optional (try/except ImportError -> None), so
  importing curateipsum.backup no longer crashes on Windows before
  cli.py's own "Not supported platform" check gets a chance to run.
- Guard set_backups_lock() against being called twice for a directory
  this process already holds: it now fails fast instead of silently
  overwriting the tracked fd in _held_locks, which would leak the
  original descriptor and leave the lock held even after
  release_backups_lock() closes only the newest one.
- Wrap the post-acquire metadata write (ftruncate/write/fsync) in a
  try/except: a failure there now releases the flock and closes the fd
  before returning False, instead of leaving the lock held with
  no caller aware of it.
- Cap _read_lock_metadata()'s read and strip newlines before logging,
  so a stale or forged lock file can't inject bogus log lines or force
  an unbounded read.

Adds a regression test for the double-acquire guard.

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-03 22:00:21 +00:00
a48eaf8117 Make snapshot creation transactional (#5)
* Make snapshot creation transactional

Build each snapshot in a private .incomplete-<name> staging directory
instead of mutating the final backup path directly. Any failure while
building the snapshot (hardlink failure, rsync error, or a single
Actions.ERROR entry) is now fatal: the staging directory is discarded
and BackupFailedError is raised, so a failed or interrupted backup can
never silently look like a success.

On success, the completion marker is written and the directory fsync'd
before an atomic os.rename() into the final snapshot name, so a crash
can never leave a partially-populated directory under a real snapshot
name.

Also:
- cleanup_stale_staging_dirs() removes abandoned staging directories
  left by a crashed run before a new backup starts.
- _reserve_snapshot_name() avoids same-second snapshot name collisions.
- cli.py releases the backup lock in finally and returns a nonzero
  exit code when BackupFailedError is raised.

Updates the error-recovery tests that previously asserted failures
were swallowed silently to assert BackupFailedError is raised instead,
matching the new fail-loud contract.

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

* Address Copilot review feedback on transactional snapshots

- Remove unused uuid import.
- _reserve_snapshot_name() also checks for a collision with an existing
  .incomplete-<name> staging directory, not just the final snapshot
  name.
- _fsync_dir() opens with O_DIRECTORY where available.
- Fsync the completion marker file itself, and fsync the parent
  backups_dir after the rename, so the marker and the rename survive a
  crash/power loss.
- Re-raise an already-BackupFailedError unchanged (preserving its
  traceback) instead of wrapping it in a new instance; only
  fs.BackupCreationError gets wrapped.
- Wrap the initial os.mkdir(staging.path) failure in BackupFailedError
  so every failure path uses the documented exception.

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-03 21:30:39 +00:00
snegov 48ebb7f9ae Remove requirements-dev.txt in favor of uv dependency management
Dependencies are now managed via pyproject.toml and installed with
'uv sync --all-extras --dev'. This aligns with the workflow update.
2026-02-05 22:37:33 -08:00
snegov 96f063d877 Update Python version requirement to 3.8 or higher 2026-02-06 04:51:30 +00:00
snegov 79b56d770f Update github workflow to use uv 2026-02-05 20:44:03 -08:00
snegov 7c54bf3823 Fix PseudoDirEntry follow_symlinks handling and add documentation
- Fix follow_symlinks parameter being ignored in is_dir(), is_file()
- Change from realpath() to abspath() to preserve symlinks
- Add separate caches for stat() and lstat() results
- Remove incorrect follow_symlinks param from is_symlink()
- Add comprehensive docstring explaining purpose and design

When follow_symlinks=False, methods now correctly return False for
symlinks instead of following them. Previously all symlinks were
resolved, breaking symlink-aware backup operations.

Fixes #8
2026-02-04 23:00:06 -08:00
snegov 52b4fef814 Add error recovery tests for backup failures
Adds 7 new tests in TestErrorRecovery class covering:
- Hardlink failure cleanup (incomplete backup removal)
- Rsync failure cleanup
- Detection of incomplete backups without marker files
- Lock handling after backup failures
- Permission error handling during cleanup

Tests verify that failed backups are properly cleaned up and don't interfere
with subsequent backup operations or lock management.
2026-02-04 22:21:02 -08:00
snegov 84c8425422 Add tests for external rsync and hardlink tools
Tests --external-rsync and --external-hardlink flags with conditional
execution based on tool availability. Verifies external tools correctly
sync files and preserve hardlinks for unchanged files.
2026-02-04 22:14:04 -08:00
snegov e9a65a9b88 Add tests for rsync edge cases
Covers real-world scenarios: broken symlinks, unicode/spaces in filenames,
empty files, and deeply nested paths. All edge cases handled correctly.
2026-02-04 22:00:55 -08:00
snegov 95614b6eec Add metadata preservation tests for copy_direntry
Verify that timestamps, permissions, and ownership are correctly
preserved when copying files, directories, and symlinks. Tests account
for filesystem behavior where reading a file updates its atime by
capturing source timestamps before the copy operation.
2026-02-04 21:56:25 -08:00
snegov 3214b48ae8 Add tests for ISO week calendar boundary edge cases
Tests cover week 53 year transitions, same week numbers across years,
and late December dates in week 1 of next year. Discovered bug #33
where weekly retention compares only week number without ISO year,
causing backups 1 year apart to be incorrectly deduplicated.
2026-02-04 21:49:23 -08:00
snegov f0894584d4 Add tests for force lock behavior with signal handling
Tests verify --force flag properly handles running processes:
- SIGTERM success (process terminates gracefully)
- SIGKILL escalation (process survives SIGTERM)
- Kill failure handling (permission errors)
2026-02-04 21:03:46 -08:00
snegov 06583f7e1a Add test for combined retention policy interaction
Add test_combined_retention_policies to verify all 5 retention policies
(keep_all, keep_daily, keep_weekly, keep_monthly, keep_yearly) work
correctly together. Test validates precedence rules where later retention
tiers can override earlier ones (e.g., monthly overrides weekly, yearly
overrides monthly) and documents threshold inclusive behavior.

Test uses 36 backups spanning 2017-2021 with expected 21 backups kept
based on policy interaction.
2026-02-04 20:58:06 -08:00
snegov fb9dcbd392 Refactor: split rsync tests into separate module
Move all rsync-related tests (44 tests) from test_fs.py to new
test_rsync.py module for better organization:
- TestRsync (14 tests)
- TestRsyncBasic (5 tests)
- TestParseRsyncOutput (17 tests)
- TestRsyncExt (8 tests)

Extract shared helper functions to conftest.py to eliminate duplication:
- create_file(), create_dir(), relpath()
- check_identical_file()
- common_fs_dirs fixture

This improves test organization and maintainability by grouping related
tests and removing code duplication between test modules.
2026-02-04 20:23:51 -08:00
snegov dc9bc2e5e4 Add comprehensive CLI tests
Implements 26 tests across 6 test classes covering all CLI functionality:
- Argument parsing and flag handling
- Platform validation (Linux/macOS only)
- External tool availability checks (rsync, cp, gcp)
- Directory validation
- Lock acquisition and release
- Backup execution flow

Brings CLI module from zero coverage to complete test coverage.
Total test count: 99 → 127 tests (all passing).
2026-02-04 19:40:59 -08:00
snegov fe48010fc9 Add comprehensive tests for rsync output parsing
Implements TestParseRsyncOutput class with 17 tests covering all code
paths in _parse_rsync_output() function. Tests cover deletions, file/
directory/symlink creation, rewrites, metadata updates (permissions,
ownership, timestamps), and error handling for invalid input.
2026-02-04 19:36:24 -08:00
snegov ee1df4cb21 Update tests 2026-02-04 19:31:29 -08:00
snegov 5dc9235992 Fix potential NameError in copy_file exception handler
Initialize file descriptors to None before opening to prevent
UnboundLocalError in the finally block if file opening fails.

Changes:
- Initialize fin = None and fout = None at function start
- Check if descriptors are not None before closing
- Remove UnboundLocalError from exception handling (no longer needed)

This improves code quality by eliminating the need to catch an error
that indicates a logic bug. The existing tests verify correct behavior.

Fixes #6
2026-02-03 22:20:52 -08:00
Claude 7c59bbc90b Fix high-priority bugs and add comprehensive test coverage
This commit addresses 8 high-priority issues identified in code analysis.

Fixes #3
Fixes #4
Fixes #5
Fixes #7
Fixes #10
Fixes #19
Fixes #20
Fixes #21

## Critical Bug Fixes

1. **Race condition in lock file creation (#3)**
   - Changed to atomic file creation using os.O_CREAT | os.O_EXCL
   - Prevents two processes from both acquiring the lock
   - Location: curateipsum/backup.py:110-115

2. **Invalid lock file error handling (#4)**
   - Added try/except for corrupted/empty lock files
   - Gracefully removes corrupted locks and retries
   - Location: curateipsum/backup.py:121-133

3. **SIGKILL vs SIGTERM issue (#5)**
   - Now sends SIGTERM first for graceful shutdown
   - Waits 5 seconds before escalating to SIGKILL
   - Allows previous process to clean up resources
   - Location: curateipsum/backup.py:146-156

4. **Wrong stat object for permissions (#7)**
   - Fixed bug where dst_stat was used instead of src_stat
   - Permissions are now correctly updated during rsync
   - Location: curateipsum/fs.py:371

5. **os.chown() fails for non-root users (#10)**
   - Wrapped all os.chown() calls in try/except blocks
   - Logs debug message instead of crashing
   - Allows backups to succeed for non-root users
   - Locations: curateipsum/fs.py:217-221, 228-231, 383-387, 469-472

## Comprehensive Test Coverage

6. **Lock file tests (#19)**
   - Added TestBackupLock class with 7 test cases
   - Tests: creation, concurrent prevention, stale locks, corruption
   - Location: tests/test_backups.py:228-330

7. **Filesystem operation tests (#20)**
   - Added tests/test_fs_extended.py with 6 test classes
   - Tests: copy_file, copy_direntry, rsync, hardlink_dir, scantree, rm_direntry
   - 20+ test cases covering normal and edge cases
   - Location: tests/test_fs_extended.py

8. **Integration tests (#21)**
   - Added tests/test_integration.py with 2 test classes
   - Tests full backup workflow end-to-end
   - Tests: incremental backups, hardlinks, delta dirs, cleanup, recovery
   - 14 test cases covering complete backup lifecycle
   - Location: tests/test_integration.py

## Test Results
All 68 tests pass successfully:
- 11 original backup cleanup tests
- 7 new lock file tests
- 16 original fs tests
- 20 new fs extended tests
- 14 new integration tests

## Impact
These fixes address critical bugs that could cause:
- Data corruption from concurrent backups
- Incomplete cleanup from forced process termination
- Permission sync failures
- Tool unusability for non-root users

The comprehensive test coverage ensures these bugs are caught early
and provides confidence for future refactoring.
2026-02-03 22:09:28 -08:00
snegov 32ce113608 Migrate to pyproject.toml with setuptools_scm
Replace custom version management with setuptools_scm to eliminate
source file modifications during installation. Version is now derived
from git tags at build time without writing to tracked files.

Changes:
- Add pyproject.toml with full project metadata
- Simplify setup.py to minimal backwards-compatible shim
- Remove curateipsum/_version.py from version control
- Add _version.py to .gitignore (auto-generated at build time)
- Use PEP 440 compliant version strings

Fixes #18
2026-02-03 21:27:49 -08:00
snegov 6fd6051642 Merge pull request #4 from snegov/claude/fix-github-actions-python-versions-011CUyaqGCtZoDZVxANHBu2n
Fix GitHub Actions Python version testing
2025-11-09 20:55:42 -08:00
Claude 22933d7520 Fix GitHub Actions workflow to support Python 3.6-3.14
Use Docker containers with official Python images to run tests across
all Python versions from 3.6 to 3.14. This approach:

- Eliminates "workers not found" errors from deprecated Ubuntu runners
- Provides reliable support for older Python versions (3.6, 3.7)
- Simplifies workflow configuration significantly
- Uses official python:X.Y Docker images maintained by Python team
- Sets fail-fast: false to test all versions independently
2025-11-10 04:50:39 +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 6d6038b027 Remove backup markers from previous backups 0.0.3 2023-05-07 22:00:05 -07:00
snegov 5746a9bc82 Update github actions 2023-05-07 15:33:45 -07:00
snegov 45c2480778 Fix spent time calculation 2023-05-06 23:20:21 -07:00
snegov 62fcfe5881 Add running backup lock and marker of finished backup 2023-05-06 23:06:15 -07:00
snegov 775234a433 Fix incorrect logging 2022-08-28 19:42:00 +04:00
snegov ae95856a58 Fix symlinks issue in nest_hardlink function 2021-11-16 00:00:19 +03:00
snegov 2631b78d4d Handle socket files 0.0.2 2021-11-15 23:12:51 +03:00
snegov 04692be42d Minor fix in printing version info 2021-11-15 10:28:48 +03:00
snegov e7774a5e5a Create run_tests.yml 2021-11-15 10:28:10 +03:00