diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b791882..76f80ad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -311,6 +311,8 @@ Repository interfaces: ``` CreateSnapshot(opts) + │ + ├─► markDestination() // Refuse a destination without the marker, then write it │ ├─► PruneDatabase() // Critical: avoid dedup errors │ @@ -367,6 +369,8 @@ CreateSnapshot(opts) ``` bucket/ +├── vaultik-destination.txt # Marker written by every backup +│ ├── blobs/ │ └── {hash[0:2]}/ │ └── {hash[2:4]}/ @@ -378,6 +382,15 @@ bucket/ └── manifest.json.zst # Blob list (for pruning/verification) ``` +While the local index holds snapshots, a destination without +`vaultik-destination.txt` is treated as not present +(`checkDestinationMarker` in `internal/vaultik/destination_marker.go`): +`snapshot create` refuses to write to it, and listing it fails as for a +destination that cannot be listed. An unplugged volume's empty mount point +is therefore never taken for a destination whose snapshots are all gone. +The `file://` backend never creates its destination directory, so a write +to a missing one fails. + The `{remote-key}` directory name is a one-way double SHA-256 hash of the human snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never written to the store as a directory name. See diff --git a/README.md b/README.md index 17ff3b9..76a9479 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 ` 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 ``` // +├── vaultik-destination.txt # Marker written by every backup ├── blobs/ │ └── // └── 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 diff --git a/docs/REPOSTRUCTURE.md b/docs/REPOSTRUCTURE.md index 0ad5884..494544c 100644 --- a/docs/REPOSTRUCTURE.md +++ b/docs/REPOSTRUCTURE.md @@ -4,7 +4,7 @@ This document describes the structure and organization of data stored in the S3 ## Overview -Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components: +Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components, plus a marker file: 1. **Blobs** - The actual backup data (content-addressed, encrypted) 2. **Metadata** - Snapshot information and manifests (partially encrypted) @@ -12,6 +12,7 @@ Vaultik stores all backup data in an S3-compatible object store. The repository ``` // +├── vaultik-destination.txt ├── blobs/ │ └── / │ └── / @@ -24,6 +25,10 @@ Vaultik stores all backup data in an S3-compatible object store. The repository The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation). +## Marker File (`vaultik-destination.txt`) + +Every backup writes this file at the root before it uploads anything. It holds one fixed line of plain text and names no host, 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, and `prune`, `snapshot purge`, `snapshot remove` and `snapshot list` behave as for a destination that cannot be listed. An unplugged volume leaves an empty directory at its mount point, and without the marker that directory would read as a destination whose snapshots are all gone. `remote nuke` deletes the marker last. + ## Blobs Directory (`blobs/`) ### Structure diff --git a/internal/cli/entry_prune_json_test.go b/internal/cli/entry_prune_json_test.go index b220df4..a326535 100644 --- a/internal/cli/entry_prune_json_test.go +++ b/internal/cli/entry_prune_json_test.go @@ -101,7 +101,8 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) { // writeHermeticPruneConfig builds a config over a temp directory with an // empty destination directory and, if seedStale is set, creates the // index database up front with one snapshot record that has no -// counterpart on the destination store. Returns the config path. +// counterpart on the destination store, and gives the destination the +// marker a backup writes. Returns the config path. func writeHermeticPruneConfig(t *testing.T, seedStale bool) string { t.Helper() @@ -131,6 +132,11 @@ func writeHermeticPruneConfig(t *testing.T, seedStale bool) string { if seedStale { seedStaleSnapshotRecord(t, indexPath) + + // While the local index holds a snapshot, prune fails on a + // destination without the marker a backup writes. + require.NoError(t, os.WriteFile( + filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600)) } return configPath diff --git a/internal/cli/entry_snapshot_purge_test.go b/internal/cli/entry_snapshot_purge_test.go index 64d7a81..3f7b8c8 100644 --- a/internal/cli/entry_snapshot_purge_test.go +++ b/internal/cli/entry_snapshot_purge_test.go @@ -41,6 +41,11 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T) // too, so the purge selects it rather than dropping it as stale. seedStaleSnapshotRecord(t, indexPath) + // While the local index holds a snapshot, purge fails on a + // destination without the marker a backup writes. + require.NoError(t, os.WriteFile( + filepath.Join(dir, "store", "vaultik-destination.txt"), nil, 0o600)) + manifestDir := filepath.Join(dir, "store", "metadata", snapshot.RemoteSnapshotKey(stalePruneSnapshotID)) require.NoError(t, os.MkdirAll(manifestDir, 0o750)) @@ -59,10 +64,12 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T) require.NoError(t, err) require.NoError(t, db.Close()) - code, _, _ := runEntry(t, flagConfig, configPath, + code, _, stderr := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce) assert.Equal(t, 1, code) + assert.Contains(t, stderr, "simulated index error", + "the purge must fail at the local-index delete") assert.FileExists(t, manifestPath, "the snapshot is still in the local index, so its metadata must stay") } diff --git a/internal/storage/file.go b/internal/storage/file.go index 83769a6..af97ef7 100644 --- a/internal/storage/file.go +++ b/internal/storage/file.go @@ -21,9 +21,13 @@ type FileStorer struct { // NewFileStorer creates a new filesystem storage backend. // // Construction is intentionally cheap and does not touch the filesystem. -// The basePath is recorded; the directory is created lazily on first -// write. Write operations (Put/PutWithProgress) call MkdirAll for the -// per-blob parent directory, which also covers basePath on first use. +// The backend never creates basePath: the user does, so that when +// basePath is a directory on an unplugged volume, a write fails instead +// of landing on the disk under the mount point. This does not hold when +// basePath is the mount point itself, which stays behind when its volume +// is unplugged. +// Put and PutWithProgress fail on a missing basePath and create the +// directories below it as needed. // Get and Stat report a key under a missing basePath as ErrNotFound. // List and ListStream fail on a missing basePath, because listing it as // an empty store would make `prune` drop every local snapshot record. @@ -299,7 +303,19 @@ func (f *FileStorer) writeAtomic( path := f.fullPath(key) dir := filepath.Dir(path) - err := f.fs.MkdirAll(dir, storageDirPerm) + // Checked on every write, so a destination that disappears part-way + // through a backup fails it rather than being recreated by MkdirAll. + _, err := f.fs.Stat(f.basePath) + if os.IsNotExist(err) { + return fmt.Errorf("destination directory does not exist; "+ + "create it, or mount the volume it is on: %w", err) + } + + if err != nil { + return fmt.Errorf("checking destination directory: %w", err) + } + + err = f.fs.MkdirAll(dir, storageDirPerm) if err != nil { return fmt.Errorf("creating directories: %w", err) } diff --git a/internal/storage/file_backend_test.go b/internal/storage/file_backend_test.go index ec84910..15cf6a8 100644 --- a/internal/storage/file_backend_test.go +++ b/internal/storage/file_backend_test.go @@ -292,3 +292,43 @@ func TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory(t *testing.T) { }) } } + +// TestFileStorerPutMissingDestination checks that a write fails when the +// destination directory has gone, even after earlier writes to it +// succeeded, and that it does not create the directory again. +func TestFileStorerPutMissingDestination(t *testing.T) { + t.Parallel() + + ctx := context.Background() + base := filepath.Join(t.TempDir(), "usbstick") + + err := os.Mkdir(base, 0o750) + if err != nil { + t.Fatalf("Mkdir: %v", err) + } + + s, err := storage.NewFileStorer(base) + if err != nil { + t.Fatalf("NewFileStorer: %v", err) + } + + err = s.Put(ctx, "blobs/aa/bb/first", strings.NewReader("first")) + if err != nil { + t.Fatalf("first Put: %v", err) + } + + err = os.Rename(base, base+"-unplugged") + if err != nil { + t.Fatalf("Rename: %v", err) + } + + err = s.Put(ctx, "blobs/aa/bb/second", strings.NewReader("second")) + if !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Put = %v, want a not-exist error", err) + } + + _, err = os.Stat(base) + if !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Put created the destination directory again (Stat = %v)", err) + } +} diff --git a/internal/storage/storer.go b/internal/storage/storer.go index 44a4861..85081f3 100644 --- a/internal/storage/storer.go +++ b/internal/storage/storer.go @@ -41,7 +41,8 @@ type Info struct { // directory for filesystem). type Storer interface { // Put stores data at the specified key. - // Parent directories are created automatically for filesystem backends. + // The filesystem backend fails when its destination directory does + // not exist, and creates the directories below it as needed. Put(ctx context.Context, key string, data io.Reader) error // PutWithProgress stores data with progress reporting. diff --git a/internal/vaultik/changed_file_restore_test.go b/internal/vaultik/changed_file_restore_test.go index 01ed0ec..e82c2b3 100644 --- a/internal/vaultik/changed_file_restore_test.go +++ b/internal/vaultik/changed_file_restore_test.go @@ -137,7 +137,7 @@ func TestBackupAfterRemovingNewestSnapshotRestoresChangedFile(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") changedPath := filepath.Join(dataDir, "appended.bin") @@ -184,7 +184,7 @@ func TestBackupAfterInterruptedRunRestoresChangedFile(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") changedPath := filepath.Join(dataDir, "appended.bin") diff --git a/internal/vaultik/deep_verify_test.go b/internal/vaultik/deep_verify_test.go index 0651bf2..63ea4f1 100644 --- a/internal/vaultik/deep_verify_test.go +++ b/internal/vaultik/deep_verify_test.go @@ -35,7 +35,7 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") chunkSize := int64(64 * 1024) @@ -94,7 +94,7 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) { fs := afero.NewOsFs() ctx := context.Background() - storeDir := filepath.Join(t.TempDir(), "remote") + storeDir := t.TempDir() cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir) @@ -151,7 +151,7 @@ func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) { fs := afero.NewOsFs() ctx := context.Background() - storeDir := filepath.Join(t.TempDir(), "remote") + storeDir := t.TempDir() cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir) @@ -183,7 +183,7 @@ func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) { t.Parallel() ctx := context.Background() - storeDir := filepath.Join(t.TempDir(), "remote") + storeDir := t.TempDir() cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir) failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}} diff --git a/internal/vaultik/destination_marker.go b/internal/vaultik/destination_marker.go new file mode 100644 index 0000000..7db1db8 --- /dev/null +++ b/internal/vaultik/destination_marker.go @@ -0,0 +1,74 @@ +package vaultik + +import ( + "errors" + "fmt" + "strings" + + "sneak.berlin/go/vaultik/internal/storage" +) + +// destinationMarkerKey is the object every backup writes at the root of +// the backup destination store. While the local index holds a snapshot, +// a destination without it is treated as not present: an unplugged +// volume leaves an empty directory at its mount point, which would +// otherwise read as a store whose snapshots are all gone. +const destinationMarkerKey = "vaultik-destination.txt" + +// destinationMarkerText is the whole content of the marker. It names no +// host, path or time. +const destinationMarkerText = "This is a vaultik backup destination store.\n" + +// errDestinationNotPresent is returned when the local index holds a +// snapshot and the backup destination store has no marker. +var errDestinationNotPresent = errors.New("backup destination store is not present") + +// checkDestinationMarker returns errDestinationNotPresent when the local +// index holds a snapshot and the backup destination store has no marker. +// With no snapshot in the local index, as on a first backup or on +// another machine, there is nothing to check. +func (v *Vaultik) checkDestinationMarker() error { + ids, err := v.localSnapshotIDs() + if err != nil { + return fmt.Errorf("listing local snapshots: %w", err) + } + + if len(ids) == 0 { + return nil + } + + _, err = v.Storage.Stat(v.ctx, destinationMarkerKey) + if errors.Is(err, storage.ErrNotFound) { + return fmt.Errorf("%w: %s has no %s, but the local index holds "+ + "snapshots stored there; mount the volume if it is removable, "+ + "or if the destination was emptied on purpose, run "+ + "'vaultik database delete' and back up again", + errDestinationNotPresent, v.Storage.Info().Location, + destinationMarkerKey) + } + + if err != nil { + return fmt.Errorf("checking for %s: %w", destinationMarkerKey, err) + } + + return nil +} + +// markDestination checks the backup destination store with +// checkDestinationMarker and then writes the marker, so a first backup +// marks its destination and a later one writes the same text again. +func (v *Vaultik) markDestination() error { + err := v.checkDestinationMarker() + if err != nil { + return err + } + + err = v.Storage.Put(v.ctx, destinationMarkerKey, + strings.NewReader(destinationMarkerText)) + if err != nil { + return fmt.Errorf("writing %s to the backup destination store: %w", + destinationMarkerKey, err) + } + + return nil +} diff --git a/internal/vaultik/destination_marker_test.go b/internal/vaultik/destination_marker_test.go new file mode 100644 index 0000000..da46c41 --- /dev/null +++ b/internal/vaultik/destination_marker_test.go @@ -0,0 +1,239 @@ +package vaultik_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/database" + "sneak.berlin/go/vaultik/internal/log" + "sneak.berlin/go/vaultik/internal/vaultik" +) + +// These tests cover https://git.eeqj.de/sneak/vaultik/issues/239: a +// file:// destination at a volume's mount point leaves an empty directory +// behind when the volume is unplugged. While the local index holds +// snapshots, a destination without vaultik-destination.txt is not present, +// so no command may write to it or conclude from it that the local +// snapshots are gone. + +// markerFile is the name of the file a backup writes at the root of the +// destination. +const markerFile = "vaultik-destination.txt" + +// backUpThenUnplugMountPoint backs up to a file:// destination, then +// moves the destination directory away and leaves an empty directory in +// its place, as unplugging a volume mounted there would. It returns the +// path of that empty directory. +func backUpThenUnplugMountPoint( + ctx context.Context, t *testing.T, +) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer, string) { + t.Helper() + + storeDir := filepath.Join(t.TempDir(), "mnt") + v, repos, out := backUpToFileDestination(ctx, t, storeDir) + + require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged")) + require.NoError(t, os.Mkdir(storeDir, 0o750)) + + return v, repos, out, storeDir +} + +// assertUntouched checks that the local index still holds its one +// snapshot and that nothing was written to the empty mount point. +func assertUntouched( + ctx context.Context, t *testing.T, + repos *database.Repositories, storeDir string, +) { + t.Helper() + + snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit) + require.NoError(t, err) + assert.Len(t, snapshots, 1, "no local snapshot record may be deleted") + + entries, err := os.ReadDir(storeDir) + require.NoError(t, err) + assert.Empty(t, entries, "nothing may be written to the empty mount point") +} + +// TestFirstBackupWritesMarker checks that a backup writes the marker +// file at the root of the destination. +func TestFirstBackupWritesMarker(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + storeDir := t.TempDir() + backUpToFileDestination(context.Background(), t, storeDir) + + assert.FileExists(t, filepath.Join(storeDir, markerFile)) +} + +// TestBackupRefusesDestinationWithoutMarker checks that snapshot create +// --prune writes nothing and drops no local snapshot record when the +// destination has no marker. +func TestBackupRefusesDestinationWithoutMarker(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + ctx := context.Background() + v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t) + + err := v.CreateSnapshot(&vaultik.SnapshotCreateOptions{ + Cron: true, + Prune: true, + }) + require.ErrorContains(t, err, markerFile) + + assertUntouched(ctx, t, repos, storeDir) +} + +// TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots checks that +// snapshot create writes nothing when the local index holds only an +// incomplete snapshot and the destination has no marker. The backup drops +// incomplete snapshots before it scans; checked after that, the local +// index would hold none and nothing would be checked. +func TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + ctx := context.Background() + storeDir := t.TempDir() + v, repos := newFileDestinationVaultik(ctx, t, storeDir) + + require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{ + ID: "host_first_2026-01-02T03:04:05Z", + Hostname: "host", + StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + })) + + err := backUp(v, "first") + require.ErrorContains(t, err, markerFile) + + assertUntouched(ctx, t, repos, storeDir) +} + +// TestPruneRefusesDestinationWithoutMarker checks that prune fails and +// deletes no local snapshot record when the destination has no marker. +func TestPruneRefusesDestinationWithoutMarker(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + ctx := context.Background() + v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t) + + err := v.Prune(&vaultik.PruneOptions{Force: true}) + require.ErrorContains(t, err, markerFile) + + assertUntouched(ctx, t, repos, storeDir) +} + +// TestPurgeRefusesDestinationWithoutMarker checks that snapshot purge +// fails and deletes no local snapshot record when the destination has no +// marker. +func TestPurgeRefusesDestinationWithoutMarker(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + ctx := context.Background() + v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t) + + err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{ + KeepLatest: true, + Force: true, + }) + require.ErrorContains(t, err, markerFile) + + assertUntouched(ctx, t, repos, storeDir) +} + +// TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot +// remove warns that the metadata could not be removed from a destination +// without a marker, instead of reporting that it was. +func TestRemoveSnapshotWarnsWhenDestinationHasNoMarker(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + ctx := context.Background() + v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t) + + result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"), + &vaultik.RemoveOptions{Force: true}) + require.NoError(t, err) + + assert.False(t, result.RemoteRemoved) + assert.Contains(t, out.String(), + "Could not remove snapshot metadata from remote") + assert.Contains(t, out.String(), markerFile) + assert.NotContains(t, out.String(), + "Removed snapshot metadata from remote storage") +} + +// TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker checks that +// snapshot remove, for a snapshot that is only on the destination, fails +// saying the destination is not present when it has no marker, instead +// of saying the snapshot does not exist. +func TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + ctx := context.Background() + storeDir := filepath.Join(t.TempDir(), "mnt") + v, repos, _ := backUpToFileDestination(ctx, t, storeDir) + require.NoError(t, backUp(v, "second")) + + id := localSnapshotID(ctx, t, repos, "first") + _, err := v.RemoveSnapshot(id, + &vaultik.RemoveOptions{Force: true, LocalOnly: true}) + require.NoError(t, err) + + // Unplug the volume: an empty directory stays at its mount point. + require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged")) + require.NoError(t, os.Mkdir(storeDir, 0o750)) + + _, err = v.RemoveSnapshot(id, &vaultik.RemoveOptions{Force: true}) + require.ErrorContains(t, err, markerFile) + require.ErrorContains(t, err, "mount the volume") + + assertUntouched(ctx, t, repos, storeDir) +} + +// TestListSnapshotsWarnsWhenDestinationHasNoMarker checks that snapshot +// list warns and shows the local index alone when the destination has no +// marker, without reporting the local snapshot as missing from it. +func TestListSnapshotsWarnsWhenDestinationHasNoMarker(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + ctx := context.Background() + v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t) + id := localSnapshotID(ctx, t, repos, "first") + + require.NoError(t, v.ListSnapshots(false)) + + assert.Contains(t, out.String(), "Could not list backup destination store") + assert.Contains(t, out.String(), markerFile) + assert.Contains(t, out.String(), id) + assert.NotContains(t, out.String(), "not found in backup destination store") +} + +// TestBackupAfterNukeRemoteSucceeds checks that remote nuke leaves the +// destination ready for a new first backup, which writes the marker +// again. +func TestBackupAfterNukeRemoteSucceeds(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + storeDir := t.TempDir() + v, _, _ := backUpToFileDestination(context.Background(), t, storeDir) + + require.NoError(t, v.NukeRemote(true)) + assert.NoFileExists(t, filepath.Join(storeDir, markerFile)) + + require.NoError(t, backUp(v, "second")) + assert.FileExists(t, filepath.Join(storeDir, markerFile)) +} diff --git a/internal/vaultik/fault_injection_test.go b/internal/vaultik/fault_injection_test.go index 1595151..0cd6e6d 100644 --- a/internal/vaultik/fault_injection_test.go +++ b/internal/vaultik/fault_injection_test.go @@ -192,7 +192,7 @@ func assertRestoreRejectsDamagedBlob( fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") @@ -238,7 +238,7 @@ func TestDeepVerifyCatchesLyingBackend(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") ctx := context.Background() @@ -290,7 +290,7 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") ctx := context.Background() @@ -298,10 +298,6 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) { writeFaultSourceTree(t, fs, dataDir) - // No upload succeeds, so nothing creates the destination directory. - // It must exist for the listing below to show that no blob survived. - require.NoError(t, os.Mkdir(storeDir, 0o750)) - inner, err := storage.NewFileStorer(storeDir) require.NoError(t, err) @@ -365,7 +361,7 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") @@ -429,7 +425,7 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") ctx := context.Background() @@ -445,6 +441,10 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) { repos := database.NewRepositories(db) + // snapshot create writes the marker; this backup runs its steps one + // by one, so the marker is written here. + require.NoError(t, inner.Put(ctx, markerFile, strings.NewReader(""))) + // Back up and complete with a working backend. sm := newFaultSnapshotManager(fs, inner, cfg, repos) scanner := newFaultScanner(fs, inner, cfg, repos) @@ -493,8 +493,14 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) { "shallow verify must report the missing manifest") // snapshot list must not crash on the partial snapshot. + var listOut strings.Builder + + reader.UI = ui.NewWithColor(&listOut, false) require.NoError(t, reader.ListSnapshots(false), "snapshot list must tolerate a partially-exported snapshot") + assert.NotContains(t, listOut.String(), + "Could not list backup destination store", + "snapshot list must read the partial snapshot from the destination") } // Scenario 2, repair: the process dies during the metadata export of a @@ -511,7 +517,7 @@ func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") @@ -686,7 +692,7 @@ func TestRestoreReportsDiskFull(t *testing.T) { osFS := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") diff --git a/internal/vaultik/integration_test.go b/internal/vaultik/integration_test.go index e2891ee..f9a90b7 100644 --- a/internal/vaultik/integration_test.go +++ b/internal/vaultik/integration_test.go @@ -771,7 +771,7 @@ func TestEndToEndFileStorage(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") @@ -964,7 +964,7 @@ func TestDedupOnlySnapshotRestores(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") diff --git a/internal/vaultik/missing_destination_test.go b/internal/vaultik/missing_destination_test.go index e3d3561..cf8cc54 100644 --- a/internal/vaultik/missing_destination_test.go +++ b/internal/vaultik/missing_destination_test.go @@ -24,13 +24,11 @@ import ( // that is not plugged in, cannot be listed. It is not an empty store, so // no command may conclude from it that the local snapshots are gone. -// backUpToFileDestination backs up the snapshot named "first" to a -// file:// destination at storeDir, which need not exist yet. Everything -// the returned Vaultik prints after the backup goes to the returned -// buffer. -func backUpToFileDestination( +// newFileDestinationVaultik returns a Vaultik that backs up the +// snapshot named "first" to a file:// destination at storeDir. +func newFileDestinationVaultik( ctx context.Context, t *testing.T, storeDir string, -) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) { +) (*vaultik.Vaultik, *database.Repositories) { t.Helper() osFs := afero.NewOsFs() @@ -49,8 +47,22 @@ func backUpToFileDestination( repos := database.NewRepositories(db) cfg := changedFileConfig(dataDir, dbPath) - v := newBackupVaultik(ctx, cfg, store, repos, db, osFs) + return newBackupVaultik(ctx, cfg, store, repos, db, osFs), repos +} + +// backUpToFileDestination creates the directory storeDir, as a user must +// before the first backup, and backs up the snapshot named "first" to a +// file:// destination there. Everything the returned Vaultik prints +// after the backup goes to the returned buffer. +func backUpToFileDestination( + ctx context.Context, t *testing.T, storeDir string, +) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) { + t.Helper() + + require.NoError(t, os.MkdirAll(storeDir, 0o750)) + + v, repos := newFileDestinationVaultik(ctx, t, storeDir) require.NoError(t, backUp(v, "first")) out := &bytes.Buffer{} @@ -75,21 +87,28 @@ func backUpThenUnplug( return v, repos, out } -// TestFirstBackupCreatesDestinationDirectory checks that a first backup -// to a destination directory that does not exist yet creates it, and -// that the destination can be listed afterwards. -func TestFirstBackupCreatesDestinationDirectory(t *testing.T) { +// TestBackupFailsWhenDestinationDirectoryMissing checks that a backup to +// a file:// destination whose directory does not exist fails before it +// records a snapshot, and does not create the directory. +func TestBackupFailsWhenDestinationDirectoryMissing(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() ctx := context.Background() - storeDir := filepath.Join(t.TempDir(), "volume", "backup") - v, _, out := backUpToFileDestination(ctx, t, storeDir) + volume := filepath.Join(t.TempDir(), "volume") + v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup")) - require.NoError(t, v.ListSnapshots(false)) + err := backUp(v, "first") + require.ErrorIs(t, err, fs.ErrNotExist) + require.ErrorContains(t, err, "create it, or mount the volume it is on") - assert.NotContains(t, out.String(), "Could not list backup destination store") - assert.NotContains(t, out.String(), "not found in backup destination store") + _, err = os.Stat(volume) + require.ErrorIs(t, err, fs.ErrNotExist, + "the backup must not create the destination directory") + + snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit) + require.NoError(t, err) + assert.Empty(t, snapshots) } // TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list diff --git a/internal/vaultik/new_chunk_size_test.go b/internal/vaultik/new_chunk_size_test.go index 5301334..c9e9254 100644 --- a/internal/vaultik/new_chunk_size_test.go +++ b/internal/vaultik/new_chunk_size_test.go @@ -39,7 +39,7 @@ func TestNewChunkSizeOfChangedFileStaysFixed(t *testing.T) { appendRandomBytes(t, fs, files, changedPath, appendedFileSize) - store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote")) + store, err := storage.NewFileStorer(t.TempDir()) require.NoError(t, err) db, err := database.New(ctx, dbPath) diff --git a/internal/vaultik/prune.go b/internal/vaultik/prune.go index 6d07e41..f8e68cc 100644 --- a/internal/vaultik/prune.go +++ b/internal/vaultik/prune.go @@ -42,9 +42,10 @@ const blobKeyParts = 4 // NukeRemote deletes every object under metadata/ and blobs/ in remote // storage, whatever its name, along with any object an upload cut off -// part-way left under a temporary `.partial` name. It returns -// successfully only when a listing afterwards finds nothing left there, -// and the next backup then starts from scratch. +// part-way left under a temporary `.partial` name, and last the +// destination marker. It returns successfully only when a listing +// afterwards finds nothing left under metadata/ and blobs/, and the next +// backup then starts from scratch. // // Refuses to run unless force is true. The caller is responsible for // confirming with the user. @@ -105,6 +106,13 @@ func (v *Vaultik) emptyDestinationStore() error { return fmt.Errorf("%d %w", len(left), errObjectsLeft) } + // Last, once the local snapshot records are gone: the next backup + // then has none and writes the marker again. + err = v.Storage.Delete(v.ctx, destinationMarkerKey) + if err != nil { + return fmt.Errorf("deleting %s: %w", destinationMarkerKey, err) + } + return nil } diff --git a/internal/vaultik/purge_local_remote_consistency_test.go b/internal/vaultik/purge_local_remote_consistency_test.go index c3d827f..72dd91c 100644 --- a/internal/vaultik/purge_local_remote_consistency_test.go +++ b/internal/vaultik/purge_local_remote_consistency_test.go @@ -35,6 +35,9 @@ func setupConsistencyTest( repos := database.NewRepositories(db) mockStorage := NewMockStorer() + // A backup wrote to this destination, so it carries the marker. + require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader(""))) + for _, id := range snapshotIDs { parts := strings.Split(id, "_") startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1]) diff --git a/internal/vaultik/purge_per_name_test.go b/internal/vaultik/purge_per_name_test.go index 700bf43..58c8af1 100644 --- a/internal/vaultik/purge_per_name_test.go +++ b/internal/vaultik/purge_per_name_test.go @@ -45,6 +45,9 @@ func setupPurgeTest( repos := database.NewRepositories(db) mockStorage := NewMockStorer() + // A backup wrote to this destination, so it carries the marker. + require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader(""))) + // Insert each snapshot into the DB and create remote metadata stubs. // Use timestamps parsed from snapshot IDs for realistic ordering. for _, id := range snapshotIDs { diff --git a/internal/vaultik/remove_quiet_test.go b/internal/vaultik/remove_quiet_test.go index c8916fd..4116780 100644 --- a/internal/vaultik/remove_quiet_test.go +++ b/internal/vaultik/remove_quiet_test.go @@ -96,6 +96,10 @@ func TestRemoveSnapshot_QuietSuppressesReport(t *testing.T) { store := newTestStorer() addManifest(t, store, "snapshot-001", nil) + // A backup wrote to this destination, so it carries the marker. + require.NoError(t, store.Put(context.Background(), markerFile, + bytes.NewReader(nil))) + tv := vaultik.NewForTesting(store) tv.Repositories = newLocalIndex(t, "snapshot-001") tv.UI.SetQuiet(true) diff --git a/internal/vaultik/remove_snapshot_test.go b/internal/vaultik/remove_snapshot_test.go index 7e13f7a..c9cc7fe 100644 --- a/internal/vaultik/remove_snapshot_test.go +++ b/internal/vaultik/remove_snapshot_test.go @@ -304,6 +304,10 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) { addBlob(t, store, blobUnique) addBlob(t, store, blobShared) + // A backup wrote to this destination, so it carries the marker. + require.NoError(t, store.Put(context.Background(), markerFile, + bytes.NewReader(nil))) + tv := vaultik.NewForTesting(store) tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002") @@ -373,6 +377,10 @@ func TestRemoveSnapshot_UnknownIDIsError(t *testing.T) { store := newTestStorer() addManifest(t, store, "snapshot-001", nil) + // A backup wrote to this destination, so it carries the marker. + require.NoError(t, store.Put(context.Background(), markerFile, + bytes.NewReader(nil))) + tv := vaultik.NewForTesting(store) tv.Repositories = newLocalIndex(t, "snapshot-001") @@ -393,7 +401,12 @@ func TestRemoveSnapshot_NoMetadataOnDestination(t *testing.T) { log.Initialize(log.Config{}) t.Parallel() - tv := vaultik.NewForTesting(newTestStorer()) + // A backup wrote to this destination, so it carries the marker. + store := newTestStorer() + require.NoError(t, store.Put(context.Background(), markerFile, + bytes.NewReader(nil))) + + tv := vaultik.NewForTesting(store) tv.Repositories = newLocalIndex(t, "snapshot-001") result, err := tv.RemoveSnapshot("snapshot-001", diff --git a/internal/vaultik/restore_another_machine_test.go b/internal/vaultik/restore_another_machine_test.go index 3f53202..3d0fde9 100644 --- a/internal/vaultik/restore_another_machine_test.go +++ b/internal/vaultik/restore_another_machine_test.go @@ -42,7 +42,7 @@ func TestRestoreOnAnotherMachine(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") diff --git a/internal/vaultik/restore_interrupt_test.go b/internal/vaultik/restore_interrupt_test.go index 28e09f0..134e3f3 100644 --- a/internal/vaultik/restore_interrupt_test.go +++ b/internal/vaultik/restore_interrupt_test.go @@ -65,7 +65,7 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) { root := t.TempDir() dataDir := filepath.Join(root, "source") - storeDir := filepath.Join(root, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(root, "restored") dbPath := filepath.Join(root, "index.sqlite") diff --git a/internal/vaultik/restore_locality_test.go b/internal/vaultik/restore_locality_test.go index 39f15a1..19e03e6 100644 --- a/internal/vaultik/restore_locality_test.go +++ b/internal/vaultik/restore_locality_test.go @@ -201,7 +201,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") diff --git a/internal/vaultik/restore_perms_test.go b/internal/vaultik/restore_perms_test.go index 9dbcfc5..837aa8d 100644 --- a/internal/vaultik/restore_perms_test.go +++ b/internal/vaultik/restore_perms_test.go @@ -107,7 +107,7 @@ func backupOneFile( require.NoError(t, afero.WriteFile(fs, srcPath, content, mode)) require.NoError(t, fs.Chmod(srcPath, mode)) - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") storer, err := storage.NewFileStorer(storeDir) diff --git a/internal/vaultik/restore_skip_errors_test.go b/internal/vaultik/restore_skip_errors_test.go index 311d21d..8c3bcf1 100644 --- a/internal/vaultik/restore_skip_errors_test.go +++ b/internal/vaultik/restore_skip_errors_test.go @@ -348,7 +348,7 @@ func backupSingleChunkFiles( require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644)) } - storer, err := storage.NewFileStorer(filepath.Join(tempDir, "remote")) + storer, err := storage.NewFileStorer(t.TempDir()) require.NoError(t, err) db, err := database.New(ctx, dbPath) diff --git a/internal/vaultik/restore_snapshot_identity_test.go b/internal/vaultik/restore_snapshot_identity_test.go index 5cdedd4..c7e6dea 100644 --- a/internal/vaultik/restore_snapshot_identity_test.go +++ b/internal/vaultik/restore_snapshot_identity_test.go @@ -29,7 +29,7 @@ func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() chunkSize := int64(64 * 1024) @@ -100,7 +100,7 @@ func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() chunkSize := int64(64 * 1024) diff --git a/internal/vaultik/restore_sweeper_integration_test.go b/internal/vaultik/restore_sweeper_integration_test.go index 13cc5cc..57b8563 100644 --- a/internal/vaultik/restore_sweeper_integration_test.go +++ b/internal/vaultik/restore_sweeper_integration_test.go @@ -186,7 +186,7 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") diff --git a/internal/vaultik/restore_sweeper_test.go b/internal/vaultik/restore_sweeper_test.go index 82729c9..1f767f3 100644 --- a/internal/vaultik/restore_sweeper_test.go +++ b/internal/vaultik/restore_sweeper_test.go @@ -34,7 +34,7 @@ func TestRestoreFreesBlobsNoPendingFileNeeds(t *testing.T) { dataDir := filepath.Join(tempDir, "source") originalsDir := filepath.Join(dataDir, "originals") copiesDir := filepath.Join(dataDir, "copies") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") originals := writeFilesWithCopies(t, fs, originalsDir, copiesDir, fileBytes) diff --git a/internal/vaultik/restore_truncated_db_test.go b/internal/vaultik/restore_truncated_db_test.go index aeedd79..4b03715 100644 --- a/internal/vaultik/restore_truncated_db_test.go +++ b/internal/vaultik/restore_truncated_db_test.go @@ -33,7 +33,7 @@ func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") diff --git a/internal/vaultik/restore_unmatched_path_test.go b/internal/vaultik/restore_unmatched_path_test.go index 44c437d..c9da298 100644 --- a/internal/vaultik/restore_unmatched_path_test.go +++ b/internal/vaultik/restore_unmatched_path_test.go @@ -32,7 +32,7 @@ func TestRestoreNamesUnmatchedPathAndRestoresTheMatchingOne(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") @@ -89,7 +89,7 @@ func TestRestoreNamesEveryPathWhenNothingMatches(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") linkDir := filepath.Join(tempDir, "srclink") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") @@ -152,7 +152,7 @@ func TestRestoreNamesUnmatchedPathWhenADamagedBlobStopsIt(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") diff --git a/internal/vaultik/reused_blob_verify_test.go b/internal/vaultik/reused_blob_verify_test.go index 68cd715..f499e0e 100644 --- a/internal/vaultik/reused_blob_verify_test.go +++ b/internal/vaultik/reused_blob_verify_test.go @@ -28,7 +28,7 @@ func TestVerifyPassesWhenBackupReusesStoredBlob(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() secondDBPath := filepath.Join(tempDir, "second.sqlite") chunkSize := int64(64 * 1024) diff --git a/internal/vaultik/same_second_rewrite_test.go b/internal/vaultik/same_second_rewrite_test.go index 998330b..80464f0 100644 --- a/internal/vaultik/same_second_rewrite_test.go +++ b/internal/vaultik/same_second_rewrite_test.go @@ -25,7 +25,7 @@ func TestBackupOfSameSecondRewriteRestoresNewContent(t *testing.T) { fs := afero.NewOsFs() tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "src") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") rewrittenPath := filepath.Join(dataDir, "small.txt") diff --git a/internal/vaultik/shallow_verify_test.go b/internal/vaultik/shallow_verify_test.go index 68fe1fe..ce5a294 100644 --- a/internal/vaultik/shallow_verify_test.go +++ b/internal/vaultik/shallow_verify_test.go @@ -36,7 +36,7 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") chunkSize := int64(32 * 1024) @@ -116,7 +116,7 @@ func TestShallowVerifyDetectsMissingDatabase(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") chunkSize := int64(32 * 1024) @@ -199,7 +199,7 @@ func TestShallowVerifyReportsStatErrorsApartFromMissing(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") chunkSize := int64(32 * 1024) @@ -288,7 +288,7 @@ func TestShallowVerifyQuietFailsOnStatErrorAlone(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") chunkSize := int64(32 * 1024) diff --git a/internal/vaultik/snapshot.go b/internal/vaultik/snapshot.go index 38f964b..df57dbc 100644 --- a/internal/vaultik/snapshot.go +++ b/internal/vaultik/snapshot.go @@ -95,6 +95,13 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error { return err } + // Before PruneDatabase: it drops incomplete snapshots, which could + // leave the local index without one and so skip the marker check. + err = v.markDestination() + if err != nil { + return err + } + // Clean up incomplete snapshots FIRST, before any scanning. // This is critical for data safety; PruneDatabase below does it. hostname := v.Config.Hostname @@ -117,21 +124,9 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error { return fmt.Errorf("prune database: %w", err) } - // Determine which snapshots to process - snapshotNames := opts.Snapshots - if len(snapshotNames) == 0 { - snapshotNames = v.Config.SnapshotNames() - } else { - // Validate requested snapshot names exist - for _, name := range snapshotNames { - if _, ok := v.Config.Snapshots[name]; !ok { - return fmt.Errorf("%w: %q", errSnapshotNotInConfig, name) - } - } - } - - if len(snapshotNames) == 0 { - return errNoSnapshotsInConfig + snapshotNames, err := v.snapshotNamesToCreate(opts.Snapshots) + if err != nil { + return err } // Process each named snapshot @@ -171,6 +166,28 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error { return nil } +// snapshotNamesToCreate returns the requested snapshot names, each checked +// against the config, or every configured name when none was requested. +func (v *Vaultik) snapshotNamesToCreate(requested []string) ([]string, error) { + snapshotNames := requested + if len(snapshotNames) == 0 { + snapshotNames = v.Config.SnapshotNames() + } else { + // Validate requested snapshot names exist + for _, name := range snapshotNames { + if _, ok := v.Config.Snapshots[name]; !ok { + return nil, fmt.Errorf("%w: %q", errSnapshotNotInConfig, name) + } + } + } + + if len(snapshotNames) == 0 { + return nil, errNoSnapshotsInConfig + } + + return snapshotNames, nil +} + // runPostBackupPrune drops older snapshots of the given names and removes // orphan blobs from remote storage. If keepNewerThan is set (e.g. "4w"), // snapshots newer than that duration are kept. Otherwise only the latest @@ -1257,10 +1274,18 @@ func (v *Vaultik) RemoveSnapshot( return v.removeSnapshotDryRun(result, snapshotID, opts) } - if !opts.Force && !opts.JSON && !v.confirmRemoveSnapshot(snapshotID, opts) { + if !v.confirmRemoveSnapshot(snapshotID, opts) { return result, nil } + // Checked before the local record is deleted: it may be the last + // snapshot in the local index, and the check applies only while the + // local index holds one. + var markerErr error + if !opts.LocalOnly { + markerErr = v.checkDestinationMarker() + } + if inLocalIndex { log.Info("Removing snapshot from local database", "snapshot_id", snapshotID) @@ -1271,7 +1296,7 @@ func (v *Vaultik) RemoveSnapshot( } result.RemoteRemoved, err = v.removeSnapshotRemote( - snapshotID, inLocalIndex, opts) + snapshotID, inLocalIndex, markerErr, opts) if err != nil { return result, err } @@ -1308,7 +1333,9 @@ func (v *Vaultik) RemoveSnapshot( // remove: no row and, unless LocalOnly is set, no metadata on the // destination store either. The destination store is consulted only // when the local index has no row, so a snapshot in the local index can -// still be removed from it while the destination store is unreachable. +// still be removed from it while the destination store is unreachable, +// and only after checkDestinationMarker passes, so an unplugged volume's +// empty mount point is not taken for a store without the snapshot. func (v *Vaultik) findSnapshotToRemove( snapshotID string, opts *RemoveOptions, ) (bool, error) { @@ -1328,6 +1355,11 @@ func (v *Vaultik) findSnapshotToRemove( errSnapshotNotFound, snapshotID) } + err := v.checkDestinationMarker() + if err != nil { + return false, err + } + objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID)) if err != nil { return false, fmt.Errorf("looking up snapshot on destination store: %w", err) @@ -1366,11 +1398,15 @@ func (v *Vaultik) removeSnapshotDryRun( } // confirmRemoveSnapshot prompts for confirmation before removing a -// snapshot. Returns false (treating EOF/read errors as "no") unless the -// user answers "y". The prompt and its answer bypass the UI: they are an -// interactive exchange the operator must see, so --quiet does not -// silence them. +// snapshot, unless --force or --json is set. Returns false (treating +// EOF/read errors as "no") unless the user answers "y". The prompt and +// its answer bypass the UI: they are an interactive exchange the +// operator must see, so --quiet does not silence them. func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool { + if opts.Force || opts.JSON { + return true + } + if opts.LocalOnly { v.stdoutf("Remove snapshot '%s' from local database "+ "(remote untouched)? [y/N] ", snapshotID) @@ -1400,15 +1436,17 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) // removeSnapshotRemote strips the snapshot's metadata from the // destination store. It returns true only when the delete finished // without an error and removed at least one object; under LocalOnly it -// does nothing. When the snapshot is only on the destination store, a -// failure is returned as an error, because that removal is all the -// command does. When the snapshot was in the local index, its removal -// there has already happened, so a failure is only a warning telling the -// user to run `vaultik snapshot remove` for the snapshot again once the -// destination store is reachable (`vaultik prune` never removes snapshot -// metadata). +// does nothing. markerErr is the result of checkDestinationMarker; when +// it is set, nothing is deleted and it is the failure. When the snapshot +// is only on the destination store, a failure is returned as an error, +// because that removal is all the command does. When the snapshot was in +// the local index, its removal there has already happened, so a failure +// is only a warning telling the user to run `vaultik snapshot remove` +// for the snapshot again once the destination store is reachable +// (`vaultik prune` never removes snapshot metadata). func (v *Vaultik) removeSnapshotRemote( - snapshotID string, inLocalIndex bool, opts *RemoveOptions, + snapshotID string, inLocalIndex bool, markerErr error, + opts *RemoveOptions, ) (bool, error) { if opts.LocalOnly { return false, nil @@ -1417,9 +1455,14 @@ func (v *Vaultik) removeSnapshotRemote( log.Info("Removing snapshot metadata from remote storage", "snapshot_id", snapshotID) - remoteKey := snapshot.RemoteSnapshotKey(snapshotID) + var deleted int + + err := markerErr + if err == nil { + deleted, err = v.deleteRemoteSnapshotByKey( + snapshot.RemoteSnapshotKey(snapshotID)) + } - deleted, err := v.deleteRemoteSnapshotByKey(remoteKey) if err != nil && !inLocalIndex { return false, fmt.Errorf( "removing snapshot metadata from destination store: %w", err) @@ -1528,8 +1571,8 @@ func (v *Vaultik) localSnapshotIDs() ([]string, error) { // listAllRemoteSnapshotKeys collects the hashed remote keys // (subdirectories under metadata/) currently present in the // destination store. Returns (nil, err) when the store cannot be -// listed; callers must treat that as "no remote info available," not -// fatal. +// listed, or is not present by checkDestinationMarker; callers must +// treat that as "no remote info available," not fatal. func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) { log.Info("Listing all remote snapshots") @@ -1578,6 +1621,11 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) { } } + err := v.checkDestinationMarker() + if err != nil { + return nil, err + } + return keys, nil } diff --git a/internal/vaultik/snapshot_failed_file_test.go b/internal/vaultik/snapshot_failed_file_test.go index a5f8bc4..2742115 100644 --- a/internal/vaultik/snapshot_failed_file_test.go +++ b/internal/vaultik/snapshot_failed_file_test.go @@ -87,7 +87,7 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) { "src": {Paths: []string{srcDir}}, } - store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote")) + store, err := storage.NewFileStorer(t.TempDir()) require.NoError(t, err) db, err := database.New(ctx, cfg.IndexPath) diff --git a/internal/vaultik/snapshot_list_test.go b/internal/vaultik/snapshot_list_test.go index 2dd88e4..ec9b97d 100644 --- a/internal/vaultik/snapshot_list_test.go +++ b/internal/vaultik/snapshot_list_test.go @@ -122,6 +122,9 @@ func newListEnv(t *testing.T) *listEnv { store := newObservingStorer() stdout := &bytes.Buffer{} + // A backup wrote to this destination, so it carries the marker. + require.NoError(t, store.Put(ctx, markerFile, strings.NewReader(""))) + v := &vaultik.Vaultik{ Config: &config.Config{ AgeSecretKey: "", diff --git a/internal/vaultik/snapshot_skip_errors_test.go b/internal/vaultik/snapshot_skip_errors_test.go index 0f88515..af07afe 100644 --- a/internal/vaultik/snapshot_skip_errors_test.go +++ b/internal/vaultik/snapshot_skip_errors_test.go @@ -45,7 +45,7 @@ func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) { "tree": {Paths: []string{srcDir}}, } - store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote")) + store, err := storage.NewFileStorer(t.TempDir()) require.NoError(t, err) db, err := database.New(ctx, cfg.IndexPath) diff --git a/internal/vaultik/snapshot_summary_test.go b/internal/vaultik/snapshot_summary_test.go index 3e180b6..a901562 100644 --- a/internal/vaultik/snapshot_summary_test.go +++ b/internal/vaultik/snapshot_summary_test.go @@ -84,7 +84,7 @@ func newSummaryEnv(t *testing.T) *summaryEnv { "split": {Paths: []string{dirOne, dirTwo}}, } - inner, err := storage.NewFileStorer(filepath.Join(tempDir, "remote")) + inner, err := storage.NewFileStorer(t.TempDir()) require.NoError(t, err) store := faultstore.New(inner) diff --git a/internal/vaultik/two_path_snapshot_test.go b/internal/vaultik/two_path_snapshot_test.go index 06c2fed..e6673c9 100644 --- a/internal/vaultik/two_path_snapshot_test.go +++ b/internal/vaultik/two_path_snapshot_test.go @@ -28,7 +28,7 @@ func TestBackupWithoutCronOfTwoPathSnapshotRestoresBothPaths(t *testing.T) { tempDir := t.TempDir() firstDir := filepath.Join(tempDir, "first") secondDir := filepath.Join(tempDir, "second") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() restoreDir := filepath.Join(tempDir, "restored") dbPath := filepath.Join(tempDir, "index.sqlite") diff --git a/internal/vaultik/uncompressed_size_test.go b/internal/vaultik/uncompressed_size_test.go index f9b77c1..9d8fd8e 100644 --- a/internal/vaultik/uncompressed_size_test.go +++ b/internal/vaultik/uncompressed_size_test.go @@ -36,7 +36,7 @@ func TestUncompressedSizeOfChangedFileStaysFixed(t *testing.T) { appendRandomBytes(t, fs, files, changedPath, appendedFileSize) - store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote")) + store, err := storage.NewFileStorer(t.TempDir()) require.NoError(t, err) db, err := database.New(ctx, dbPath) diff --git a/internal/vaultik/verify_quiet_test.go b/internal/vaultik/verify_quiet_test.go index b4350db..c8dd630 100644 --- a/internal/vaultik/verify_quiet_test.go +++ b/internal/vaultik/verify_quiet_test.go @@ -28,7 +28,7 @@ func TestVerify_QuietSuppressesReport(t *testing.T) { tempDir := t.TempDir() dataDir := filepath.Join(tempDir, "source") - storeDir := filepath.Join(tempDir, "remote") + storeDir := t.TempDir() dbPath := filepath.Join(tempDir, "index.sqlite") chunkSize := int64(32 * 1024)