Refuse a backup destination an unplugged volume left empty (closes #239)
check / check (push) Waiting to run

Every backup writes vaultik-destination.txt at the destination root.
While the local index holds snapshots, a destination without it is
not present: snapshot create refuses before writing, prune and
snapshot purge fail, and snapshot list and snapshot remove act as for
a destination that cannot be listed. snapshot create checks before it
drops incomplete snapshots, snapshot remove before it looks on the
destination. remote nuke deletes the marker last. The file:// backend
never creates its destination directory; that protects a directory on
a removable volume, not a mount point.

Tests back up into directories that exist; fixtures for a written
destination carry the marker.

Judgement call: the marker is rewritten on every backup.
Deviation: for the complexity limit, two blocks moved, unchanged, into
snapshotNamesToCreate and confirmRemoveSnapshot.

Model: opus-5-5
This commit is contained in:
2026-10-14 01:58:22 +00:00
parent aeac897805
commit 724b9fb3e6
42 changed files with 659 additions and 123 deletions
+48 -16
View File
@@ -24,6 +24,10 @@ grep 'public key' vaultik_backup_private_key.txt
vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
# create the destination directory; vaultik never creates it, so a backup
# to a volume that is not plugged in fails instead of filling the disk
mkdir /Volumes/usbstick/mybackup
# macOS only: grant your terminal app Full Disk Access first
# (System Settings → Privacy & Security → Full Disk Access), otherwise
# the backup will abort with a permission error on protected directories
@@ -298,6 +302,10 @@ value where a list belongs or a word where a number belongs), is refused.
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`).
Without `--prune`, or with a value that is not a valid duration, the
command fails before the backup starts.
* Fails before it writes anything when a `file://` destination directory
does not exist, or when the local index holds snapshots and the
destination has no `vaultik-destination.txt` (see
[remote storage layout](#remote-storage-layout))
**`snapshot list`**: Show every snapshot known to this host — the union
of the local index and the backup destination store — with timestamps
@@ -327,7 +335,10 @@ invocation that reconciles them.
If the destination store cannot be listed (unmounted volume,
permission denied, network down), the command warns, falls back to the
local index alone, and still exits zero.
local index alone, and still exits zero. It does the same when the
local index holds snapshots and the destination has no
`vaultik-destination.txt`, as when a volume mounted at the destination
is unplugged and leaves its empty mount point behind.
* `--json`: Output in JSON format. Each entry carries `locally_tracked`
(whether the snapshot is in the local index), `remote_key` (the full
64-character storage key), and `remote_present` (whether it was seen
@@ -367,13 +378,14 @@ snapshot in the local index or on the destination store is an error;
with `--local-only`, the snapshot must be in the local index. Local row
cleanup (files, chunks, blobs the snapshot was the last referrer for)
runs automatically. If removing the snapshot's metadata from the
destination store fails (for example because the store is unreachable),
the local-DB removal still completes and a warning is emitted; run
`vaultik snapshot remove <snapshot-id>` again once the store is reachable
to remove the snapshot's metadata from it (`vaultik prune` does not). For
a snapshot that is only on the destination store, that failure is an
error. To wipe everything on the destination in one go, use `vaultik
remote nuke --force`.
destination store fails (for example because the store is unreachable,
or the local index holds snapshots and the destination has no
`vaultik-destination.txt`), the local-DB removal still completes and a
warning is emitted; run `vaultik snapshot remove <snapshot-id>` again
once the store is reachable to remove the snapshot's metadata from it
(`vaultik prune` does not). For a snapshot that is only on the
destination store, that failure is an error. To wipe everything on the
destination in one go, use `vaultik remote nuke --force`.
* `--local-only`: Skip remote cleanup; only touch the local index
* `--dry-run`: Show what would be deleted without deleting
* `--force`: Skip confirmation prompt
@@ -404,8 +416,12 @@ manifest on the destination store to compute the still-referenced blob
set and delete any blob not in that set. Step (3) reads all remote
manifests — network cost scales with the number of snapshots. `snapshot
create --prune` runs the same cleanup automatically; this is the
manual entry point for the same work. If a blob cannot be deleted, the
command (and `snapshot create --prune`) exits non-zero.
manual entry point for the same work. If the destination store cannot
be listed, or the local index holds snapshots and the destination has
no `vaultik-destination.txt` (an unplugged volume's empty mount point),
prune fails and deletes nothing. `snapshot purge` does the same. If a
blob cannot be deleted, the command (and `snapshot create --prune`)
exits non-zero.
* `--force`: Skip confirmation prompt
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
`--force` does. A blob that cannot be deleted is counted in
@@ -429,11 +445,12 @@ since `prune` never deletes it.
* `--json`: Output as JSON
**`remote nuke`**: Delete every object under `metadata/` and `blobs/` on
the backup destination store, whatever its name, leaving the bucket prefix
empty. Destructive and irreversible. This is the single supported way to
wipe the entire destination store. If a listing afterwards still finds
anything there, the command names each object left and exits non-zero;
running it again deletes what is left.
the backup destination store, whatever its name, and then
`vaultik-destination.txt`, leaving the bucket prefix empty. Destructive
and irreversible. This is the single supported way to wipe the entire
destination store. If a listing afterwards still finds anything under
`metadata/` or `blobs/`, the command names each object left and exits
non-zero; running it again deletes what is left.
* `--force`: Required to confirm destruction.
**`database delete`**: Delete the local SQLite state database file
@@ -458,7 +475,12 @@ object store. Credentials are read from `s3.access_key_id` and
a local or mounted filesystem. Useful for testing or backing up to a NAS. The
path must be absolute; a relative one is refused when the config loads.
Percent-escapes in the path are decoded, so `my%20stick` and `my stick` name
the same directory.
the same directory. The directory must exist before the first backup: vaultik
never creates it. A directory on a removable volume is gone while the volume is
not mounted, so a backup then fails instead of writing to the disk under the
mount point. The mount point itself stays behind when the volume is unplugged:
there only `vaultik-destination.txt` stops such a backup, and only while the
local index holds snapshots.
**Rclone** (`rclone://remote/path`): Uses rclone's 70+ supported cloud
providers. Requires rclone to be configured separately (`rclone config`).
@@ -489,6 +511,7 @@ For an implementation-level view of the internals — the data model, the
```
<bucket>/<prefix>/
├── vaultik-destination.txt # Marker written by every backup
├── blobs/
│ └── <aa>/<bb>/<full_blob_hash>
└── metadata/
@@ -497,6 +520,15 @@ For an implementation-level view of the internals — the data model, the
└── manifest.json.zst # Unencrypted blob list (for pruning)
```
* `vaultik-destination.txt` holds one fixed line of text, with no host
name, path or time. While the local index holds snapshots, a
destination without it is treated as not present: `snapshot create`
refuses to write to it, `prune` and `snapshot purge` fail, and
`snapshot list` and `snapshot remove` behave as for a destination that
cannot be listed. This keeps an unplugged volume's empty mount point
from being taken for a destination whose snapshots are all gone. Mount
the volume if it is removable; if the destination was emptied on
purpose, run `vaultik database delete` and back up again.
* Blobs are two-level directory sharded using the first 4 hex chars of the blob hash
* `db.zst.age` is a binary SQLite database (zstd compressed, age encrypted)
containing all file metadata, chunk mappings, and relationships for the snapshot