3 Commits
Author SHA1 Message Date
sneak 5a33adf70a Refuse a backup destination an unplugged volume left empty (closes #239)
check / check (push) Canceled after 0s
Every backup now writes vaultik-destination.txt at the destination
root. While the local index holds snapshots, a destination without it
is not present: snapshot create refuses before writing, prune and
snapshot purge fail, and snapshot list and snapshot remove warn.
remote nuke deletes the marker last. The file:// backend no longer
creates its destination directory and checks for it on every write;
that protects a directory on a removable volume, not the mount point
itself.

Tests back up into existing directories; fixtures standing for a
written destination carry the marker.

Judgement call: the marker is rewritten on every backup.
Deviation: for the complexity limit, snapshot name selection left CreateSnapshot and the --force/--json test moved into confirmRemoveSnapshot, both unchanged.

Model: opus-5-5
2026-10-13 18:55:47 +00:00
clawbot 3361e9ebd9 Record each snapshot's uncompressed size when its backup runs (closes #360)
check / check (push) Canceled after 0s
snapshot list summed the chunks a snapshot reaches through its file rows
in the local index. A file's row is shared by every snapshot that lists
it and rewritten when the file changes, so backing up a changed file
changed every older snapshot's UNCOMPRESSED SIZE.

The backup now takes that total when it records the snapshot's stats,
into a new snapshots column, uncompressed_chunk_size, and snapshot list
reads the column, as it already does for new_chunk_size.

Model: opus-5-5
2026-10-13 20:43:19 +02:00
clawbot fb8abcd049 Stop restore --verify re-reporting files that failed to restore (closes #323)
check / check (push) Canceled after 0s
With --skip-errors, a file that failed to restore was verified anyway,
failed again because it was never written, and was added to the same
failure list as the restore failure. restore --verify then listed it
twice and called every entry a checksum mismatch.

Verification now skips the files that failed to restore and keeps its
own failures in a separate list, so failed restores and failed
verifications are reported apart, each path once. The restore still
exits non-zero. The tests cover a restore with skipped files where
nothing fails verification, and one where another restored file also
fails it.

Judgement call: the second test has its own trailing-byte file-system
wrapper; the one in restore_perms_test.go is in the internal test package.

Model: opus-5-5
2026-10-13 20:12:48 +02:00
42 changed files with 837 additions and 141 deletions
+13
View File
@@ -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
+52 -16
View File
@@ -24,6 +24,10 @@ grep 'public key' vaultik_backup_private_key.txt
vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
# create the destination directory; vaultik never creates it, so a backup
# to a volume that is not plugged in fails instead of filling the disk
mkdir /Volumes/usbstick/mybackup
# macOS only: grant your terminal app Full Disk Access first
# (System Settings → Privacy & Security → Full Disk Access), otherwise
# the backup will abort with a permission error on protected directories
@@ -289,12 +293,17 @@ needed.
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
and three sizes per snapshot (compressed remote size; total
uncompressed chunk size; size of the chunks that snapshot stored that
were not stored before, recorded when its backup ran).
were not stored before). Both chunk sizes are recorded when the
snapshot's backup runs.
Listing the destination store does **not** require the age secret key,
so it works in vaultik's intended configuration, where the backed-up
@@ -317,7 +326,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
@@ -355,11 +367,13 @@ prune` invocation to run as a follow-up. An ID that names no 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 the destination store is unreachable, the local-DB
removal still completes and a warning is emitted; run `vaultik snapshot
remove <snapshot-id>` again once the store is reachable to remove the
snapshot's metadata from it (`vaultik prune` does not). To wipe everything
on the destination in one go, use `vaultik remote nuke --force`.
runs automatically. If the destination store is unreachable, or the
local index holds snapshots and the destination has no
`vaultik-destination.txt`, the local-DB removal still completes and a
warning is emitted; run `vaultik snapshot remove <snapshot-id>` again
once the store is reachable to remove the snapshot's metadata from it
(`vaultik prune` does not). 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
@@ -375,7 +389,9 @@ on the destination in one go, use `vaultik remote nuke --force`.
* Optional path arguments to restore specific files/directories (default: all)
* Preserves file permissions, timestamps, ownership (ownership requires root),
symlinks, and empty directories
* `--verify`: After restoring, verify every file's chunk hashes match
* `--verify`: After restoring, verify every restored file's chunk hashes
match. A file skipped under `--skip-errors` is reported as failed to
restore and is not verified.
**`prune`**: Tidy up everything that isn't needed. Runs three passes:
(1) reconcile the local index against the destination store — any
@@ -386,8 +402,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
@@ -410,11 +430,12 @@ under `blobs/` whose name is not a blob hash is not counted as a blob,
since `prune` never deletes it.
* `--json`: Output as JSON
**`remote nuke`**: Delete every snapshot's metadata and every blob from the
backup destination store, leaving the bucket prefix empty. Destructive and
irreversible. This is the single supported way to wipe the entire
destination store. If anything cannot be deleted, the command exits
non-zero; running it again deletes what is left.
**`remote nuke`**: Delete every snapshot's metadata, every blob and
`vaultik-destination.txt` from the backup destination store, leaving the
bucket prefix empty. Destructive and irreversible. This is the single
supported way to wipe the entire destination store. If anything cannot
be deleted, the command exits non-zero; running it again deletes what is
left.
* `--force`: Required to confirm destruction.
**`database delete`**: Delete the local SQLite state database file
@@ -439,7 +460,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`).
@@ -470,6 +496,7 @@ For an implementation-level view of the internals — the data model, the
```
<bucket>/<prefix>/
├── vaultik-destination.txt # Marker written by every backup
├── blobs/
│ └── <aa>/<bb>/<full_blob_hash>
└── metadata/
@@ -478,6 +505,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` warn, 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
+1
View File
@@ -122,6 +122,7 @@ Tracks backup snapshots.
- `new_chunk_size` (INTEGER) - Total size of those chunks before compression; `snapshot list` shows it as NEW CHUNK SIZE
- `blob_count` (INTEGER) - Number of blobs this snapshot created
- `total_size` (INTEGER) - Total size of all files
- `uncompressed_chunk_size` (INTEGER) - Total size before compression of the distinct chunks the snapshot's files reference, recorded when its backup ran; `snapshot list` shows it as UNCOMPRESSED SIZE
- `blob_size` (INTEGER) - Total compressed size of all referenced blobs
- `blob_uncompressed_size` (INTEGER) - Total uncompressed size of all referenced blobs
- `compression_ratio` (REAL) - Compression ratio achieved
+6 -1
View File
@@ -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
```
<bucket>/<prefix>/
├── vaultik-destination.txt
├── blobs/
│ └── <hash[0:2]>/
│ └── <hash[2:4]>/
@@ -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
+7 -1
View File
@@ -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
+1
View File
@@ -89,6 +89,7 @@ CREATE TABLE IF NOT EXISTS snapshots (
new_chunk_size INTEGER NOT NULL DEFAULT 0,
blob_count INTEGER NOT NULL DEFAULT 0,
total_size INTEGER NOT NULL DEFAULT 0,
uncompressed_chunk_size INTEGER NOT NULL DEFAULT 0,
blob_size INTEGER NOT NULL DEFAULT 0,
blob_uncompressed_size INTEGER NOT NULL DEFAULT 0,
compression_ratio REAL NOT NULL DEFAULT 1.0,
+23 -17
View File
@@ -120,7 +120,11 @@ func (r *SnapshotRepository) UpdateCounts(
return nil
}
// UpdateExtendedStats updates extended statistics for a snapshot
// UpdateExtendedStats updates extended statistics for a snapshot. It also
// records the total plaintext size of the distinct chunks the snapshot's
// files reference. Only the snapshot's own backup can take that total: a
// file's row is shared by every snapshot that lists it, and a later
// backup of a changed file rewrites the row with the new chunks.
func (r *SnapshotRepository) UpdateExtendedStats(
ctx context.Context,
tx *sql.Tx,
@@ -145,7 +149,17 @@ func (r *SnapshotRepository) UpdateExtendedStats(
compression_level = ?,
upload_bytes = ?,
upload_duration_ms = ?,
new_chunk_size = ?
new_chunk_size = ?,
uncompressed_chunk_size = (
SELECT COALESCE(SUM(c.size), 0)
FROM (
SELECT DISTINCT fc.chunk_hash
FROM snapshot_files sf
JOIN file_chunks fc ON sf.file_id = fc.file_id
WHERE sf.snapshot_id = snapshots.id
) sc
JOIN chunks c ON sc.chunk_hash = c.chunk_hash
)
WHERE id = ?
`
@@ -555,30 +569,22 @@ func (r *SnapshotRepository) GetSnapshotBlobSizes(
return compressed, uncompressed, nil
}
// GetSnapshotUncompressedChunkSize returns the sum of plaintext sizes of all unique
// chunks referenced by a snapshot (via snapshot_files → file_chunks → chunks).
// GetSnapshotUncompressedChunkSize returns the total plaintext size of the
// distinct chunks the snapshot's files reference, as the snapshot's backup
// recorded it (see UpdateExtendedStats).
func (r *SnapshotRepository) GetSnapshotUncompressedChunkSize(
ctx context.Context, snapshotID string,
) (int64, error) {
query := `
SELECT COALESCE(SUM(c.size), 0)
FROM (
SELECT DISTINCT fc.chunk_hash
FROM snapshot_files sf
JOIN file_chunks fc ON sf.file_id = fc.file_id
WHERE sf.snapshot_id = ?
) sc
JOIN chunks c ON sc.chunk_hash = c.chunk_hash
`
query := `SELECT uncompressed_chunk_size FROM snapshots WHERE id = ?`
var totalSize int64
var uncompressedChunkSize int64
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&totalSize)
err := r.db.conn.QueryRowContext(ctx, query, snapshotID).Scan(&uncompressedChunkSize)
if err != nil {
return 0, fmt.Errorf("querying uncompressed chunk size: %w", err)
}
return totalSize, nil
return uncompressedChunkSize, nil
}
// GetSnapshotNewChunkSize returns the total plaintext size of the chunks the
+20 -4
View File
@@ -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.
@@ -297,7 +301,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)
}
+42
View File
@@ -4,7 +4,9 @@ import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"sneak.berlin/go/vaultik/internal/storage"
@@ -60,3 +62,43 @@ func TestFileStorerListMissingDestination(t *testing.T) {
t.Errorf("ListStream error = %v, want a not-exist error", streamErr)
}
}
// 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)
}
}
+2 -1
View File
@@ -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.
@@ -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")
+1 -1
View File
@@ -30,7 +30,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)
+74
View File
@@ -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
}
+184
View File
@@ -0,0 +1,184 @@
package vaultik_test
import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
"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)
}
// 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")
}
// 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))
}
+7 -11
View File
@@ -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()
@@ -511,7 +507,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 +682,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")
+2 -2
View File
@@ -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")
+35 -16
View File
@@ -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
+1 -1
View File
@@ -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)
+10 -2
View File
@@ -37,8 +37,9 @@ const blobKeyParts = 4
// NukeRemote deletes every snapshot's metadata and every blob from remote
// storage, along with any object an upload cut off part-way left under a
// temporary `.partial` name. After this returns successfully the bucket
// prefix is empty and the next backup starts from scratch.
// temporary `.partial` name, and the destination marker. After this
// returns successfully the bucket prefix is empty and the next backup
// starts from scratch.
//
// Refuses to run unless force is true. The caller is responsible for
// confirming with the user.
@@ -93,6 +94,13 @@ func (v *Vaultik) emptyDestinationStore() error {
}
}
// 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
}
@@ -34,6 +34,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])
+3
View File
@@ -44,6 +44,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 {
+10 -1
View File
@@ -303,6 +303,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")
@@ -392,7 +396,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",
+27 -12
View File
@@ -103,11 +103,12 @@ type RestoreResult struct {
BlobsDownloaded int
BytesDownloaded int64
Duration time.Duration
FilesFailed int
FailedFiles []string // Paths of files that failed to restore
// Verification results (only populated if Verify option is set)
FilesVerified int
BytesVerified int64
FilesFailed int
FailedFiles []string // Paths of files that failed verification
FilesVerified int
BytesVerified int64
VerifyFailedFiles []string // Paths of restored files that failed verification
}
// Restore restores files from a snapshot to the target directory
@@ -658,20 +659,34 @@ func (v *Vaultik) handleRestoreVerification(
opts *RestoreOptions,
result *RestoreResult,
) error {
err := v.verifyRestoredFiles(v.ctx, repos, files, opts.TargetDir, result)
// A file that failed to restore was not written and is already
// reported as such, so it is not verified.
failedToRestore := make(map[string]bool, len(result.FailedFiles))
for _, path := range result.FailedFiles {
failedToRestore[path] = true
}
restored := make([]*database.File, 0, len(files))
for _, file := range files {
if !failedToRestore[file.Path.String()] {
restored = append(restored, file)
}
}
err := v.verifyRestoredFiles(v.ctx, repos, restored, opts.TargetDir, result)
if err != nil {
return fmt.Errorf("verification failed: %w", err)
}
if result.FilesFailed > 0 {
if len(result.VerifyFailedFiles) > 0 {
v.UI.Errorf("Verification failed: %s files did not match expected checksums.",
v.UI.Count(result.FilesFailed))
v.UI.Count(len(result.VerifyFailedFiles)))
for _, path := range result.FailedFiles {
for _, path := range result.VerifyFailedFiles {
v.UI.Detailf("%s", v.UI.Path(path))
}
return fmt.Errorf("%d %w", result.FilesFailed, errFilesFailedVerify)
return fmt.Errorf("%d %w", len(result.VerifyFailedFiles), errFilesFailedVerify)
}
v.UI.Completef("Verified %s files (%s).",
@@ -1433,8 +1448,8 @@ func (v *Vaultik) verifyRestoredFiles(
if err != nil {
log.Error("File verification failed", "path", file.Path, "error", err)
result.FilesFailed++
result.FailedFiles = append(result.FailedFiles, file.Path.String())
result.VerifyFailedFiles = append(
result.VerifyFailedFiles, file.Path.String())
}
bytesProcessed += file.Size
@@ -1450,7 +1465,7 @@ func (v *Vaultik) verifyRestoredFiles(
log.Info("Verification complete",
"files_verified", result.FilesVerified,
"bytes_verified", ubytes(result.BytesVerified),
"files_failed", result.FilesFailed,
"files_failed", len(result.VerifyFailedFiles),
)
return nil
@@ -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")
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -197,7 +197,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")
+1 -1
View File
@@ -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)
+127 -1
View File
@@ -4,7 +4,9 @@ import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/afero"
@@ -104,6 +106,130 @@ func assertRestoreSkipsLostFiles(
}
}
// TestRestoreSkipErrorsVerifyChecksOnlyRestoredFiles restores with
// SkipErrors and Verify after one blob of a two-blob snapshot was
// deleted. Verification must check only the files that were written, so
// a file of the deleted blob is reported as failed to restore and not
// also as failing verification, and Restore must still return an error.
func TestRestoreSkipErrorsVerifyChecksOnlyRestoredFiles(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
backup := backupThenDeleteOneBlob(ctx, t)
var out bytes.Buffer
v := newReaderVaultik(ctx, backup.cfg, backup.storer, nil, backup.fs)
v.UI = ui.NewWithColor(&out, false)
err := v.Restore(&vaultik.RestoreOptions{
SnapshotID: backup.snapshotID,
TargetDir: backup.restoreDir,
Verify: true,
SkipErrors: true,
})
require.Error(t, err, "restore must fail when files were skipped")
assert.Contains(t, err.Error(),
fmt.Sprintf("%d file(s) failed to restore", len(backup.lost)))
written := len(backup.files) - len(backup.lost)
assert.Contains(t, out.String(), fmt.Sprintf("Verifying %d files", written))
assert.Contains(t, out.String(), fmt.Sprintf("Verified %d files", written))
assert.NotContains(t, out.String(), "Verification failed")
}
// TestRestoreSkipErrorsVerifyReportsFailuresApart restores with
// SkipErrors and Verify after one blob of a two-blob snapshot was
// deleted, while one file that does restore is written one byte too long.
// The restore warning must list only the files of the deleted blob, the
// verification summary only the long file, each path once, and Restore
// must return an error.
func TestRestoreSkipErrorsVerifyReportsFailuresApart(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
backup := backupThenDeleteOneBlob(ctx, t)
var long string
for path := range backup.files {
if !backup.lost[path] {
long = path
break
}
}
var out bytes.Buffer
fs := trailingByteFs{Fs: backup.fs, suffix: long}
v := newReaderVaultik(ctx, backup.cfg, backup.storer, nil, fs)
v.UI = ui.NewWithColor(&out, false)
err := v.Restore(&vaultik.RestoreOptions{
SnapshotID: backup.snapshotID,
TargetDir: backup.restoreDir,
Verify: true,
SkipErrors: true,
})
require.Error(t, err, "restore must fail when files failed")
_, afterWarning, found := strings.Cut(out.String(),
fmt.Sprintf("%d file(s) failed to restore:", len(backup.lost)))
require.Truef(t, found, "no restore warning in:\n%s", out.String())
warning, summary, found := strings.Cut(afterWarning,
"Verification failed: 1 files did not match")
require.Truef(t, found, "no verification summary in:\n%s", out.String())
for path := range backup.lost {
assert.Equalf(t, 1, strings.Count(warning, path),
"%s must be listed once as failed to restore", path)
assert.NotContainsf(t, summary, path,
"%s was not restored and must not fail verification", path)
}
assert.NotContainsf(t, warning, long,
"%s was restored and must not be listed as failed to restore", long)
assert.Equalf(t, 1, strings.Count(summary, long),
"%s must be listed once as failing verification", long)
}
// trailingByteFs appends one byte, on close, to the file it opens whose
// path ends in suffix, so that file is restored one byte too long and
// fails verification.
type trailingByteFs struct {
afero.Fs
suffix string
}
//nolint:ireturn // afero.Fs.OpenFile is defined to return the interface
func (fs trailingByteFs) OpenFile(
name string, flag int, perm os.FileMode,
) (afero.File, error) {
f, err := fs.Fs.OpenFile(name, flag, perm)
if err != nil || !strings.HasSuffix(name, fs.suffix) {
return f, err
}
return trailingByteFile{File: f}, nil
}
type trailingByteFile struct {
afero.File
}
func (f trailingByteFile) Close() error {
_, _ = f.Write([]byte{'x'})
return f.File.Close()
}
// TestRestoreMissingBlobAbortsWithoutSkipErrors checks that a deleted blob
// still ends the restore with an error when SkipErrors is not set.
func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
@@ -219,7 +345,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)
@@ -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)
@@ -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")
@@ -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")
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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")
+4 -4
View File
@@ -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)
@@ -110,7 +110,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)
@@ -193,7 +193,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)
@@ -282,7 +282,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)
+69 -26
View File
@@ -94,6 +94,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
@@ -116,21 +123,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
@@ -170,6 +165,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
@@ -1242,10 +1259,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)
@@ -1256,7 +1281,7 @@ func (v *Vaultik) RemoveSnapshot(
}
if !opts.LocalOnly {
result.RemoteRemoved = v.removeSnapshotRemote(snapshotID, opts)
result.RemoteRemoved = v.removeSnapshotRemote(snapshotID, markerErr, opts)
}
if v.SnapshotManager != nil {
@@ -1348,9 +1373,13 @@ 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".
// snapshot, unless --force or --json is set. Returns false (treating
// EOF/read errors as "no") unless the user answers "y".
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)
@@ -1382,15 +1411,24 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
// removal has already happened, so the user is told the remote half
// didn't finish and to run `vaultik snapshot remove` for the snapshot
// again once the destination store is reachable (`vaultik prune` never
// removes snapshot metadata). Returns true when the remote removal
// succeeded and deleted something.
func (v *Vaultik) removeSnapshotRemote(snapshotID string, opts *RemoveOptions) bool {
// removes snapshot metadata). markerErr is the result of
// checkDestinationMarker; when it is set, nothing is deleted and it is
// the failure reported. Returns true when the remote removal succeeded
// and deleted something.
func (v *Vaultik) removeSnapshotRemote(
snapshotID string, markerErr error, opts *RemoveOptions,
) bool {
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 {
log.Warn("Could not remove snapshot metadata from remote storage; "+
"run '"+snapshotRemoveCommandHint+"' with the snapshot's ID "+
@@ -1494,8 +1532,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")
@@ -1544,6 +1582,11 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
}
}
err := v.checkDestinationMarker()
if err != nil {
return nil, err
}
return keys, nil
}
@@ -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)
+11 -7
View File
@@ -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: "",
@@ -483,13 +486,14 @@ func TestListSnapshots_UnreadableManifestDoesNotHideOthers(t *testing.T) {
//
//nolint:tagliatelle // snake_case is the established output format
type listJSONRow struct {
ID string `json:"id"`
RemoteKey string `json:"remote_key"`
Timestamp string `json:"timestamp"`
CompressedSize int64 `json:"compressed_size"`
NewChunkSize int64 `json:"new_chunk_size"`
LocallyTracked bool `json:"locally_tracked"`
RemotePresent *bool `json:"remote_present"`
ID string `json:"id"`
RemoteKey string `json:"remote_key"`
Timestamp string `json:"timestamp"`
CompressedSize int64 `json:"compressed_size"`
UncompressedSize int64 `json:"uncompressed_size"`
NewChunkSize int64 `json:"new_chunk_size"`
LocallyTracked bool `json:"locally_tracked"`
RemotePresent *bool `json:"remote_present"`
}
// decodeListJSON parses the command's stdout, which must contain
@@ -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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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")
@@ -0,0 +1,85 @@
package vaultik_test
import (
"bytes"
"context"
"path/filepath"
"testing"
"github.com/spf13/afero"
"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"
)
// TestUncompressedSizeOfChangedFileStaysFixed covers
// https://git.eeqj.de/sneak/vaultik/issues/360. A backup that changes a
// file leaves the earlier snapshot's UNCOMPRESSED SIZE as it was when that
// snapshot's backup completed. The file's row in the local index is shared
// by both snapshots and rewritten by the second backup.
func TestUncompressedSizeOfChangedFileStaysFixed(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
dbPath := filepath.Join(tempDir, "index.sqlite")
changedPath := filepath.Join(dataDir, "changed.bin")
ctx := context.Background()
files := writeFaultSourceTree(t, fs, dataDir)
cfg := changedFileConfig(dataDir, dbPath)
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
store, err := storage.NewFileStorer(t.TempDir())
require.NoError(t, err)
db, err := database.New(ctx, dbPath)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
require.NoError(t, backUp(v, "first"))
// With nothing to compare against, sizeOfNewChunks totals every
// distinct chunk of the files.
firstID := localSnapshotID(ctx, t, repos, "first")
firstSize := sizeOfNewChunks(t, files, nil)
require.Equal(t, firstSize, listedUncompressedSizes(t, v)[firstID],
"the first snapshot must count the chunks of every file it holds")
appendRandomBytes(t, fs, files, changedPath, faultChunkSize)
require.NoError(t, backUp(v, "second"))
secondID := localSnapshotID(ctx, t, repos, "second")
sizes := listedUncompressedSizes(t, v)
assert.Equal(t, firstSize, sizes[firstID],
"a later backup must not change the first snapshot's figure")
assert.Equal(t, sizeOfNewChunks(t, files, nil), sizes[secondID],
"the second snapshot must count the changed file's chunks")
}
// listedUncompressedSizes runs `snapshot list --json` and returns each
// listed snapshot's uncompressed_size by snapshot ID.
func listedUncompressedSizes(t *testing.T, v *vaultik.Vaultik) map[string]int64 {
t.Helper()
var out bytes.Buffer
v.Stdout = &out
require.NoError(t, v.ListSnapshots(true))
sizes := make(map[string]int64)
for _, row := range decodeListJSON(t, out.String()) {
sizes[row.ID] = row.UncompressedSize
}
return sizes
}
+1 -1
View File
@@ -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)