From 7af92c67ffc1b88ae60dd9daad4bb0a690719ccc Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 13 Oct 2026 01:16:22 +0000 Subject: [PATCH] Refuse a backup destination an unplugged volume left empty (closes #239) Every backup writes vaultik-destination.txt at the destination root. While the local index holds snapshots stored there, 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 dropping incomplete snapshots, snapshot remove before looking on the destination. remote nuke deletes the marker last. The file:// backend never creates its destination directory, which 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 --- ARCHITECTURE.md | 13 + README.md | 64 +++- docs/REPOSTRUCTURE.md | 7 +- internal/cli/entry_prune_json_test.go | 8 +- internal/cli/entry_snapshot_purge_test.go | 9 +- internal/storage/file.go | 24 +- internal/storage/file_backend_test.go | 40 +++ internal/storage/storer.go | 3 +- internal/vaultik/changed_file_restore_test.go | 4 +- internal/vaultik/deep_verify_test.go | 8 +- internal/vaultik/destination_marker.go | 90 ++++++ internal/vaultik/destination_marker_test.go | 278 ++++++++++++++++++ internal/vaultik/fault_injection_test.go | 28 +- internal/vaultik/integration_test.go | 4 +- internal/vaultik/missing_destination_test.go | 51 +++- internal/vaultik/new_chunk_size_test.go | 2 +- internal/vaultik/prune.go | 14 +- .../purge_local_remote_consistency_test.go | 3 + internal/vaultik/purge_per_name_test.go | 3 + internal/vaultik/remove_quiet_test.go | 4 + internal/vaultik/remove_snapshot_test.go | 15 +- .../vaultik/restore_another_machine_test.go | 2 +- internal/vaultik/restore_interrupt_test.go | 2 +- internal/vaultik/restore_locality_test.go | 2 +- internal/vaultik/restore_perms_test.go | 2 +- internal/vaultik/restore_skip_errors_test.go | 2 +- .../vaultik/restore_snapshot_identity_test.go | 4 +- .../restore_sweeper_integration_test.go | 2 +- internal/vaultik/restore_sweeper_test.go | 2 +- internal/vaultik/restore_truncated_db_test.go | 2 +- .../vaultik/restore_unmatched_path_test.go | 6 +- internal/vaultik/reused_blob_verify_test.go | 2 +- internal/vaultik/same_second_rewrite_test.go | 2 +- internal/vaultik/shallow_verify_test.go | 8 +- internal/vaultik/snapshot.go | 116 +++++--- .../vaultik/snapshot_deleted_count_test.go | 2 +- internal/vaultik/snapshot_failed_file_test.go | 2 +- internal/vaultik/snapshot_list_test.go | 3 + internal/vaultik/snapshot_skip_errors_test.go | 2 +- internal/vaultik/snapshot_summary_test.go | 2 +- internal/vaultik/two_path_snapshot_test.go | 2 +- internal/vaultik/uncompressed_size_test.go | 2 +- internal/vaultik/verify_quiet_test.go | 2 +- 43 files changed, 719 insertions(+), 124 deletions(-) create mode 100644 internal/vaultik/destination_marker.go create mode 100644 internal/vaultik/destination_marker_test.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 53acfeb..8079402 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) ``` +A destination without `vaultik-destination.txt` is treated as not present +while the local index holds snapshots stored there (`checkDestinationMarker` +in `internal/vaultik/destination_marker.go`): `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 those commands from taking an unplugged volume's empty mount point 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 f0cc9ba..a84d9ff 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 @@ -331,7 +339,10 @@ gives: revert the configuration, or run `vaultik database delete`. 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 +destination has no `vaultik-destination.txt` while the local index +holds snapshots stored there, 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 @@ -373,13 +384,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 @@ -410,8 +422,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 @@ -435,11 +451,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 @@ -464,7 +481,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`). @@ -495,6 +517,7 @@ For an implementation-level view of the internals — the data model, the ``` // +├── vaultik-destination.txt # Marker written by every backup ├── blobs/ │ └── // └── metadata/ @@ -503,6 +526,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. A destination without it is treated as not + present while the local index holds snapshots stored there: + `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..36711c7 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. A destination without it is treated as not present while the local index holds snapshots stored there: `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..7ce437d --- /dev/null +++ b/internal/vaultik/destination_marker.go @@ -0,0 +1,90 @@ +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 +// stored there, 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 stored on the backup destination store and the store has no +// marker. +var errDestinationNotPresent = errors.New("backup destination store is not present") + +// checkDestinationMarker returns errDestinationNotPresent when the local +// index holds a snapshot stored on the backup destination store and the +// 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; nor +// when the local index is bound to a different destination than the +// configured one, since its snapshots are stored there. +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 + } + + // Config is nil only in tests, which then record no binding (see + // EnsureStorageBinding). + if v.Config != nil { + stored, configured, err := v.storageBinding() + if err != nil { + return err + } + + if stored != "" && stored != configured { + 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..03775d6 --- /dev/null +++ b/internal/vaultik/destination_marker_test.go @@ -0,0 +1,278 @@ +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/storage" + "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") +} + +// TestListSnapshotsAfterStorageURLChangeToEmptyDirectory checks that +// snapshot list, after storage_url is changed to an empty directory, +// warns only that the local index is bound to a different destination: +// the local snapshots are stored at the old one, so the new one's lack +// of a marker says nothing. With --json the local row is not on the new +// destination, so remote_present is false, not unknown. +func TestListSnapshotsAfterStorageURLChangeToEmptyDirectory(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + ctx := context.Background() + v, repos, out := backUpToFileDestination(ctx, t, t.TempDir()) + id := localSnapshotID(ctx, t, repos, "first") + + newDir := t.TempDir() + store, err := storage.NewFileStorer(newDir) + require.NoError(t, err) + + v.Storage = store + v.Config.StorageURL = "file://" + newDir + + require.NoError(t, v.ListSnapshots(false)) + + assert.Contains(t, out.String(), id) + assert.Contains(t, out.String(), + "local index is bound to a different backup destination") + assert.NotContains(t, out.String(), "Could not list backup destination store") + assert.NotContains(t, out.String(), markerFile) + + out.Reset() + require.NoError(t, v.ListSnapshots(true)) + + rows := decodeListJSON(t, out.String()) + require.Len(t, rows, 1) + require.NotNil(t, rows[0].RemotePresent) + assert.False(t, *rows[0].RemotePresent) +} + +// 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 805cec3..c3f2f5d 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") @@ -687,7 +693,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 3adeff9..7c9b5fe 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 @@ -1281,10 +1298,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) @@ -1295,7 +1320,7 @@ func (v *Vaultik) RemoveSnapshot( } result.RemoteRemoved, err = v.removeSnapshotRemote( - snapshotID, inLocalIndex, opts) + snapshotID, inLocalIndex, markerErr, opts) if err != nil { return result, err } @@ -1332,7 +1357,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) { @@ -1352,6 +1379,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) @@ -1390,11 +1422,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) @@ -1424,15 +1460,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 @@ -1441,9 +1479,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) @@ -1552,8 +1595,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") @@ -1602,6 +1645,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_deleted_count_test.go b/internal/vaultik/snapshot_deleted_count_test.go index bce2a4c..80dbd0a 100644 --- a/internal/vaultik/snapshot_deleted_count_test.go +++ b/internal/vaultik/snapshot_deleted_count_test.go @@ -53,7 +53,7 @@ func newDeletedCountEnv(t *testing.T, files map[string][]byte) *deletedCountEnv cfg.IndexPath = filepath.Join(tempDir, "index.sqlite") cfg.Snapshots = map[string]config.SnapshotConfig{} - 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_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 ee60b3c..811d545 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)