4 Commits
Author SHA1 Message Date
sneak 17ab7b16ee Skip vaultik's own index and PID file in a backup (closes #421)
check / check (push) Failing after 20m56s
A backup of a directory that holds the local index stored a torn copy
of it, its -wal and -shm side files and vaultik.pid, all being written
while they were read. The scan now treats these files as excluded,
whatever the config says. Their paths come from index_path and the lock
directory, with symlinks in their directories resolved the way source
paths are. If index_path is itself a symlink, SQLite writes the file it
points to, so that file and its side files are skipped too. pidlock now
owns the lock directory and the PID file name, so the CLI and the
scanner use the same path. database delete now removes the side files
database.IndexFiles lists, the -journal file included.

Model: opus-5-5
2026-11-11 03:40:02 +00:00
clawbot 0d0c76fa24 Exit 1 when a deletion prompt is not confirmed (closes #423)
check / check (push) Failing after 21m27s
prune, snapshot purge, snapshot remove and database delete exited 0 when their confirmation prompt was declined or left unanswered, so a script could not tell that nothing was deleted. They now end with "deletion not confirmed" and exit 1. The [y/N] prompts accept y or Y; database delete still asks for yes.

Model: opus-5-5
2026-11-11 04:09:45 +01:00
clawbot 88a2aa7953 Shorten internal/vaultik tests under the race detector (closes #416)
check / check (push) Successful in 9m11s
The internal/vaultik tests took 36 to 56 seconds under go test -race and ran past the 90-second timeout when the host was busy. Tests that built the same backup now copy one shared backup, and the interruption tests use smaller sources. No assertion is weakened and no test is removed. Measured back to back on next under the same load, the package takes 55% of its former time.

Model: opus-5-5
2026-11-11 03:25:41 +01:00
clawbot 098ed80990 Keep only open work in docs/TODO.md (closes #440)
check / check (push) Failing after 22m25s
sneak ruled that TODO is not a history document. docs/TODO.md drops the Completed Steps section and every other record of past work, and keeps the open steps. The Workflow step that moved a finished Next Step into Completed Steps now deletes it.

Model: opus-5-5
2026-11-11 02:41:30 +01:00
18 changed files with 304 additions and 1306 deletions

No files matched your search

+3 -1205
View File
File diff suppressed because it is too large. Load diff
+13 -13
View File
@@ -6,7 +6,9 @@ import (
"github.com/spf13/cobra"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// NewDatabaseCommand creates the database command group
@@ -90,27 +92,25 @@ Use --force to skip the confirmation prompt.`,
var confirm string
_, err = fmt.Scanln(&confirm)
// A failure to read the answer means no confirmation.
_, err = fmt.Fscanln(cmd.InOrStdin(), &confirm)
if err != nil || confirm != "yes" {
_, _ = fmt.Fprintln(w, "Aborted.")
//nolint:nilerr // a failed/aborted confirmation is a clean abort
return nil
return vaultik.ErrNotConfirmed
}
}
// Delete the database file
err = os.Remove(dbPath)
if err != nil {
return fmt.Errorf("failed to delete database: %w", err)
// Delete the database file and its side files. A side file
// may not exist, so only failing to delete the database file
// is an error.
for _, indexFile := range database.IndexFiles(dbPath) {
err = os.Remove(indexFile)
if err != nil && indexFile == dbPath {
return fmt.Errorf("failed to delete database: %w", err)
}
}
// Also delete WAL and SHM files if they exist
walPath := dbPath + "-wal"
shmPath := dbPath + "-shm"
_ = os.Remove(walPath) // Ignore errors - files may not exist
_ = os.Remove(shmPath)
out.Infof("Local state database deleted: %s.", dbPath)
log.Info("Local state database deleted", "path", dbPath)
+75
View File
@@ -0,0 +1,75 @@
package cli //nolint:testpackage // sets the unexported rootFlags directly
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestDatabaseDeleteDeclinedIsError checks that answering anything but
// "yes" keeps the database and returns ErrNotConfirmed, so the command
// exits non-zero.
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestDatabaseDeleteDeclinedIsError(t *testing.T) {
dir := t.TempDir()
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
var out bytes.Buffer
cmd := newDatabaseDeleteCommand()
cmd.SetOut(&out)
cmd.SetErr(io.Discard)
cmd.SetIn(strings.NewReader("no\n"))
cmd.SetArgs(nil)
err := cmd.Execute()
if !errors.Is(err, vaultik.ErrNotConfirmed) {
t.Errorf("declined database delete returned %v, want %v",
err, vaultik.ErrNotConfirmed)
}
_, err = os.Stat(dbPath)
if err != nil {
t.Errorf("declined database delete removed the file: %v", err)
}
if !strings.Contains(out.String(), "Aborted.") {
t.Errorf("declined database delete printed %q, want Aborted.",
out.String())
}
}
// TestDatabaseDeleteRemovesSideFiles checks that the side files SQLite
// keeps beside the database file are deleted with it.
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestDatabaseDeleteRemovesSideFiles(t *testing.T) {
dir := t.TempDir()
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
indexFiles := database.IndexFiles(dbPath)
for _, indexFile := range indexFiles {
seedFile(t, dir, filepath.Base(indexFile), "data")
}
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
mustExecute(t, newDatabaseDeleteCommand(), "--force")
for _, indexFile := range indexFiles {
_, err := os.Stat(indexFile)
if !os.IsNotExist(err) {
t.Errorf("database delete left %s: stat err = %v", indexFile, err)
}
}
}
@@ -11,14 +11,14 @@ import (
"github.com/stretchr/testify/require"
)
// TestEntrySnapshotCreateSkipsOwnFiles backs up a directory that holds
// vaultik's index and PID file beside one other file, with no exclude
// pattern set. The summary counts only the other file. See
// TestEntrySnapshotCreateSkipsIndexAndLockFiles backs up a directory
// that holds vaultik's index and PID file beside one other file, with no
// exclude pattern set. The summary counts only the other file. See
// https://git.eeqj.de/sneak/vaultik/issues/421.
//
// Not parallel: it replaces os.Args, os.Stdout, os.Stderr and the xdg
// globals.
func TestEntrySnapshotCreateSkipsOwnFiles(t *testing.T) {
func TestEntrySnapshotCreateSkipsIndexAndLockFiles(t *testing.T) {
for _, testCase := range []struct {
name string
// linked sets index_path to a symlink outside the backed-up
@@ -13,16 +13,16 @@ import (
// A backup of the directory that holds vaultik's index and PID file
// stores neither, with no exclude pattern set, and still stores the
// files beside them. See https://git.eeqj.de/sneak/vaultik/issues/421.
func TestScanSkipsOwnFiles(t *testing.T) {
func TestScanSkipsIndexAndLockFiles(t *testing.T) {
t.Parallel()
const dataDir = "/home/user/.local/share/vaultik"
ownFiles := append(database.IndexFiles(dataDir+"/index.sqlite"),
indexAndLockFiles := append(database.IndexFiles(dataDir+"/index.sqlite"),
dataDir+"/vaultik.pid")
fs := afero.NewMemMapFs()
for _, p := range append(ownFiles, dataDir+"/notes.txt") {
for _, p := range append(indexAndLockFiles, dataDir+"/notes.txt") {
require.NoError(t, afero.WriteFile(fs, p, []byte("data"), 0o600))
}
@@ -41,7 +41,7 @@ func TestScanSkipsOwnFiles(t *testing.T) {
CompressionLevel: 3,
AgeRecipients: []string{
"age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"},
OwnFiles: ownFiles,
IndexAndLockFiles: indexAndLockFiles,
})
require.NotNil(t, scanner)
@@ -57,7 +57,7 @@ func TestScanSkipsOwnFiles(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, stored, "a file beside the index is stored")
for _, p := range ownFiles {
for _, p := range indexAndLockFiles {
f, err := repos.Files.GetByPath(ctx, p)
require.NoError(t, err)
require.Nil(t, f, "%s is not stored", p)
+14 -13
View File
@@ -64,7 +64,7 @@ func provideScannerFactory(
EnableProgress: params.EnableProgress,
UI: params.UI,
Exclude: excludes,
OwnFiles: ownFiles(cfg.IndexPath, pidlock.Dir()),
IndexAndLockFiles: indexAndLockFiles(cfg.IndexPath, pidlock.Dir()),
SkipErrors: params.SkipErrors,
PreviousSnapshotID: params.PreviousSnapshotID,
DeviceNumber: params.DeviceNumber,
@@ -72,21 +72,21 @@ func provideScannerFactory(
}
}
// ownFiles returns the paths of the index at indexPath, its side files
// and the PID file in lockDir. Symlinks in their directories are resolved,
// as they are in the snapshot's source paths, so the scan finds them under
// the same names. If indexPath is a symlink, the file it points to and
// that file's side files are returned too.
func ownFiles(indexPath, lockDir string) []string {
// indexAndLockFiles returns the paths of the index at indexPath, its side
// files and the PID file in lockDir. Symlinks in their directories are
// resolved, as they are in the snapshot's source paths, so the scan finds
// them under the same names. If indexPath is a symlink, the file it points
// to and that file's side files are returned too.
func indexAndLockFiles(indexPath, lockDir string) []string {
paths := append(database.IndexFiles(indexPath), pidlock.Path(lockDir))
for i, p := range paths {
paths[i] = resolveDir(p)
paths[i] = resolveParentDir(p)
}
// SQLite writes the file a symlinked index points to and keeps the
// side files beside that file. The database package creates the index
// before the scanner is built, so the link resolves.
index := resolveDir(indexPath)
index := resolveParentDir(indexPath)
target, err := filepath.EvalSymlinks(index)
if err == nil && target != index {
@@ -96,10 +96,11 @@ func ownFiles(indexPath, lockDir string) []string {
return paths
}
// resolveDir returns the absolute form of path with the symlinks in its
// directory resolved. If the directory cannot be resolved, it returns the
// absolute form unresolved. If path has no absolute form, it returns path.
func resolveDir(path string) string {
// resolveParentDir returns the absolute form of path with the symlinks in
// its parent directory resolved. If the parent directory cannot be
// resolved, it returns the absolute form unresolved. If path has no
// absolute form, it returns path.
func resolveParentDir(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
return path
+3 -3
View File
@@ -1,4 +1,4 @@
//nolint:testpackage // exercises the unexported ownFiles helper
//nolint:testpackage // exercises the unexported indexAndLockFiles helper
package snapshot
import (
@@ -11,7 +11,7 @@ import (
// The scan walks source paths with symlinks resolved, so vaultik's own
// files are named the same way, or a symlinked home would back them up.
func TestOwnFilesResolvesSymlinkedDirectory(t *testing.T) {
func TestIndexAndLockFilesResolvesSymlinkedDirectory(t *testing.T) {
t.Parallel()
realDir, err := filepath.EvalSymlinks(t.TempDir())
@@ -20,7 +20,7 @@ func TestOwnFilesResolvesSymlinkedDirectory(t *testing.T) {
link := filepath.Join(t.TempDir(), "link")
require.NoError(t, os.Symlink(realDir, link))
paths := ownFiles(filepath.Join(link, "index.sqlite"), link)
paths := indexAndLockFiles(filepath.Join(link, "index.sqlite"), link)
require.Equal(t, []string{
filepath.Join(realDir, "index.sqlite"),
+14 -14
View File
@@ -67,10 +67,10 @@ type Scanner struct {
currentSourcePath string
exclude []string // Glob patterns for files/directories to exclude
compiledExclude []compiledPattern // Compiled glob patterns
// ownFiles holds the paths of vaultik's own working files, which the
// scan treats as excluded (ScannerConfig.OwnFiles).
ownFiles map[string]struct{}
progress *ProgressReporter
// indexAndLockFiles holds the paths of vaultik's own working files,
// which the scan treats as excluded (ScannerConfig.IndexAndLockFiles).
indexAndLockFiles map[string]struct{}
progress *ProgressReporter
// skipErrors skips files that cannot be opened or read (logged loudly);
// packer, database, encryption, and upload errors still abort the run.
skipErrors bool
@@ -136,11 +136,11 @@ type ScannerConfig struct {
EnableProgress bool // Enable the live progress reporter (ETAs, throughput)
UI *ui.Writer // Where user-facing scanner messages go; nil = discard
Exclude []string // Glob patterns for files/directories to exclude
// OwnFiles are the absolute paths of vaultik's own working files: the
// index, its side files and the PID file. The scan treats them as
// excluded, whatever Exclude says, since a copy taken while they are
// being written is torn.
OwnFiles []string
// IndexAndLockFiles are the absolute paths of vaultik's own working
// files: the index, its side files and the PID file. The scan treats
// them as excluded, whatever Exclude says, since a copy taken while
// they are being written is torn.
IndexAndLockFiles []string
// SkipErrors skips files that cannot be opened or read (log loudly but
// continue); packer, database, encryption, and upload errors still abort.
SkipErrors bool
@@ -234,9 +234,9 @@ func NewScanner(cfg ScannerConfig) *Scanner {
deviceNumber = deviceNumberFromStat
}
ownFiles := make(map[string]struct{}, len(cfg.OwnFiles))
for _, p := range cfg.OwnFiles {
ownFiles[filepath.Clean(p)] = struct{}{}
indexAndLockFiles := make(map[string]struct{}, len(cfg.IndexAndLockFiles))
for _, p := range cfg.IndexAndLockFiles {
indexAndLockFiles[filepath.Clean(p)] = struct{}{}
}
return &Scanner{
@@ -250,7 +250,7 @@ func NewScanner(cfg ScannerConfig) *Scanner {
ageRecipient: strings.Join(cfg.AgeRecipients, ","),
exclude: cfg.Exclude,
compiledExclude: compiledExclude,
ownFiles: ownFiles,
indexAndLockFiles: indexAndLockFiles,
progress: progress,
skipErrors: cfg.SkipErrors,
previousSnapshotID: cfg.PreviousSnapshotID,
@@ -2392,7 +2392,7 @@ func doubleStarVariants(pattern string) []string {
// filePath is the full path to the file
// rootPath is the root of the backup source directory
func (s *Scanner) shouldExclude(filePath, rootPath string) bool {
if _, own := s.ownFiles[filePath]; own {
if _, ok := s.indexAndLockFiles[filePath]; ok {
return true
}
+84 -12
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/spf13/afero"
@@ -24,19 +25,28 @@ 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.
// newFileDestinationVaultik returns a Vaultik that backs up the
// snapshot named "first" to a file:// destination at storeDir.
// newFileDestinationVaultik writes the fault source tree and returns a
// Vaultik that backs it up to a file:// destination at storeDir.
func newFileDestinationVaultik(
ctx context.Context, t *testing.T, storeDir string,
) (*vaultik.Vaultik, *database.Repositories) {
t.Helper()
osFs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
dbPath := filepath.Join(tempDir, "index.sqlite")
writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
writeFaultSourceTree(t, osFs, dataDir)
return openFileDestinationVaultik(ctx, t, storeDir, dataDir,
filepath.Join(tempDir, "index.sqlite"))
}
// openFileDestinationVaultik returns a Vaultik that backs up dataDir to a
// file:// destination at storeDir, with its local index at dbPath. The
// index is closed when the test ends.
func openFileDestinationVaultik(
ctx context.Context, t *testing.T, storeDir, dataDir, dbPath string,
) (*vaultik.Vaultik, *database.Repositories) {
t.Helper()
store, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
@@ -48,22 +58,84 @@ func newFileDestinationVaultik(
repos := database.NewRepositories(db)
cfg := changedFileConfig(dataDir, dbPath)
return newBackupVaultik(ctx, cfg, store, repos, db, osFs), repos
return newBackupVaultik(ctx, cfg, store, repos, db, afero.NewOsFs()), repos
}
// firstBackup is one backup of the snapshot named "first" to a file://
// destination. dir holds its source tree in src, its store in store and
// its closed local index in index/index.sqlite. The first test that
// needs it makes it, and TestMain removes it. Every test that copies it
// backs up the same source tree, so no test may change that tree.
//
//nolint:gochecknoglobals // made once and shared by the tests that copy it
var firstBackup struct {
once sync.Once
dir string
ready bool
}
// TestMain removes firstBackup once every test has run.
func TestMain(m *testing.M) {
code := m.Run()
_ = os.RemoveAll(firstBackup.dir)
os.Exit(code)
}
// makeFirstBackup writes the fault source tree and backs it up as the
// snapshot named "first", all in a new directory that it records in
// firstBackup.
func makeFirstBackup(ctx context.Context, t *testing.T) {
t.Helper()
dir, err := os.MkdirTemp("", "vaultik-first-backup-")
require.NoError(t, err)
firstBackup.dir = dir
storeDir := filepath.Join(dir, "store")
dataDir := filepath.Join(dir, "src")
indexDir := filepath.Join(dir, "index")
require.NoError(t, os.Mkdir(storeDir, 0o750))
require.NoError(t, os.Mkdir(indexDir, 0o750))
writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
v, _ := openFileDestinationVaultik(ctx, t, storeDir, dataDir,
filepath.Join(indexDir, "index.sqlite"))
require.NoError(t, backUp(v, "first"))
require.NoError(t, v.DB.Close())
firstBackup.ready = true
}
// 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.
// before the first backup, and returns a Vaultik whose file://
// destination there holds a backup of the snapshot named "first". The
// store and the local index are copies of firstBackup's, which is faster
// than backing up in every test. The returned Vaultik backs up a source
// tree shared with every other caller, so no test may change that tree.
// 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))
firstBackup.once.Do(func() { makeFirstBackup(ctx, t) })
require.True(t, firstBackup.ready, "another test failed to make firstBackup")
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
require.NoError(t, backUp(v, "first"))
require.NoError(t, os.MkdirAll(storeDir, 0o750))
require.NoError(t, os.CopyFS(storeDir,
os.DirFS(filepath.Join(firstBackup.dir, "store"))))
indexDir := t.TempDir()
require.NoError(t, os.CopyFS(indexDir,
os.DirFS(filepath.Join(firstBackup.dir, "index"))))
v, repos := openFileDestinationVaultik(ctx, t, storeDir,
filepath.Join(firstBackup.dir, "src"),
filepath.Join(indexDir, "index.sqlite"))
out := &bytes.Buffer{}
v.Stdout = out
+1 -1
View File
@@ -267,7 +267,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
if !v.confirmPrune(opts, len(unreferencedBlobs), totalSize,
dirsWithoutManifest) {
return nil
return ErrNotConfirmed
}
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
+2 -1
View File
@@ -176,7 +176,8 @@ func TestPruneDeclinedKeepsMetadataDirWithoutManifest(t *testing.T) {
env.v.Stdin = strings.NewReader("n\n")
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
require.ErrorIs(t, env.v.PruneBlobs(&vaultik.PruneOptions{}),
vaultik.ErrNotConfirmed)
assert.True(t, env.store.hasKey(leftDB),
"a declined prune must keep the directory")
+5 -3
View File
@@ -54,9 +54,10 @@ func TestPurgeSnapshots_QuietPromptListsSnapshots(t *testing.T) {
v.UI.SetQuiet(true)
// Stdin is empty, so the prompt reads no answer and cancels.
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
}))
})
require.ErrorIs(t, err, vaultik.ErrNotConfirmed)
assert.Contains(t, out.String(), snapHomeT0)
assert.Contains(t, out.String(), "Delete 1 snapshot(s)? [y/N]")
@@ -77,7 +78,8 @@ func TestPruneBlobs_QuietPromptShowsBlobTotal(t *testing.T) {
addBlob(t, env.store.testStorer, testBlobHashA)
// Stdin is empty, so the prompt reads no answer and cancels.
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
require.ErrorIs(t, env.v.PruneBlobs(&vaultik.PruneOptions{}),
vaultik.ErrNotConfirmed)
assert.Contains(t, env.stdout.String(),
"Found 1 unreferenced blob(s) totaling")
+21
View File
@@ -337,6 +337,27 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) {
assert.Contains(t, out, "vaultik prune")
}
// TestRemoveSnapshot_DeclinedIsError checks that answering no to the
// prompt keeps the snapshot and returns ErrNotConfirmed, so the command
// exits non-zero.
func TestRemoveSnapshot_DeclinedIsError(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001")
tv.Stdin.WriteString("n\n")
_, err := tv.RemoveSnapshot("snapshot-001", &vaultik.RemoveOptions{})
require.ErrorIs(t, err, vaultik.ErrNotConfirmed)
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Contains(t, tv.Stdout.String(), "Cancelled")
}
func TestRemoveSnapshot_DryRun(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
+7 -7
View File
@@ -46,11 +46,11 @@ import (
// slice out a few KB — currently the dominant cost in restore.
//
// The test deliberately constructs an adversarial scenario: three
// blobs A/B/C of ~6 MB each, nine files distributed across them, and
// path-ordered names that interleave the blobs (a1, b1, c1, a2, b2,
// c2, …) so naive path-order processing would touch every blob before
// finishing any of them.
// localitySource is one 1 MiB fixture file used by the locality test.
// blobs A/B/C of five 64 KiB chunks each, nine files distributed across
// them, and path-ordered names that interleave the blobs (a1, b1, c1,
// a2, b2, c2, …) so naive path-order processing would touch every blob
// before finishing any of them.
// localitySource is one 64 KiB fixture file used by the locality test.
type localitySource struct {
path string
data []byte
@@ -65,10 +65,10 @@ type localityCopy struct {
// localitySourceBytes is the size of each source file of the locality
// fixture.
const localitySourceBytes = 1024 * 1024
const localitySourceBytes = 64 * 1024
// buildLocalityFixture writes the adversarial source layout described
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
// in TestRestoreLocalityAndReadAt: 15 sources of 64 KiB each (which the
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
// (3 per blob group).
func buildLocalityFixture(
@@ -27,10 +27,10 @@ import (
// TestRestoreSweeperEvictsBlobs exercises the reference-counted blob
// disk cache eviction during restore.
//
// The scenario: 30 unique 1 MB random files plus 10 duplicates of those
// (40 files total, 30 MB of unique content) get backed up with a 10 MB
// blob_size_limit. After backup the snapshot's encrypted blobs are
// restored through Vaultik.Restore, and per-key Get counts on the
// The scenario: 30 unique 128 KiB random files plus 10 duplicates of
// those (40 files total, 3.75 MiB of unique content) get backed up with
// a 1 MiB blob_size_limit. After backup the snapshot's encrypted blobs
// are restored through Vaultik.Restore, and per-key Get counts on the
// storage layer are recorded. Each blob in the snapshot MUST be
// downloaded exactly once — re-downloads would mean the sweeper either
// evicted a blob that was still needed (LRU regression) or that the
@@ -41,7 +41,7 @@ import (
// original AND every duplicate referencing its chunks have been
// restored.
// buildSweeperFixture writes the source layout for the sweeper test: 30
// unique 1 MB random files plus 10 duplicates of a random subset. The
// unique 128 KiB random files plus 10 duplicates of a random subset. The
// PRNG seed is fixed so failures are reproducible; the entropy is what
// matters here — the FastCDC chunker needs realistic-looking data to
// pick chunk boundaries naturally. Returns the expected content by
@@ -51,7 +51,7 @@ func buildSweeperFixture(
) map[string][]byte {
t.Helper()
const fileSize = 1 * 1024 * 1024
const fileSize = 128 * 1024
rng := rand.New(rand.NewSource(42)) //nolint:gosec // G404: fixture data only
@@ -159,7 +159,7 @@ func runSweeperBackup(
require.NoError(t, err)
require.Equal(t, uniqueFiles+duplicateFiles, scanResult.FilesScanned)
require.Greater(t, scanResult.BlobsCreated, 1,
"30 MB of unique data at 10 MB blob size should yield multiple blobs")
"3.75 MiB of unique data at 1 MiB blob size should yield multiple blobs")
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
@@ -199,8 +199,8 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
expected := buildSweeperFixture(t, fs, dataDir, uniqueFiles, duplicateFiles)
chunkSize := int64(64 * 1024)
maxBlobSize := int64(10 * 1024 * 1024)
chunkSize := int64(16 * 1024)
maxBlobSize := int64(1024 * 1024)
storer, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
+4 -9
View File
@@ -881,17 +881,12 @@ func (v *Vaultik) confirmAndExecutePurge(
var confirm string
// A failure to read the answer means no confirmation.
_, err := v.scanStdin(&confirm)
if err != nil {
if err != nil || strings.ToLower(confirm) != "y" {
v.printlnStdout("Cancelled")
return nil //nolint:nilerr // treat EOF or read error as "no"
}
if strings.ToLower(confirm) != "y" {
v.printlnStdout("Cancelled")
return nil
return ErrNotConfirmed
}
} else if !quiet {
v.UI.Infof("The following snapshots will be deleted:")
@@ -1490,7 +1485,7 @@ func (v *Vaultik) RemoveSnapshot(
}
if !v.confirmRemoveSnapshot(snapshotID, opts) {
return result, nil
return result, ErrNotConfirmed
}
// Checked before the local record is deleted: it may be the last
+33 -7
View File
@@ -187,6 +187,17 @@ func (e *listEnv) addRemoteRawTimestamp(
t.Helper()
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
e.putRemoteManifest(t, remoteKey,
encodeRemoteManifest(t, remoteKey, timestamp))
return remoteKey
}
// encodeRemoteManifest returns the encoded manifest of a one-blob
// snapshot whose remote key is remoteKey.
func encodeRemoteManifest(t *testing.T, remoteKey, timestamp string) []byte {
t.Helper()
manifest := &snapshot.Manifest{
// Note: the hashed key, never the human ID. That is precisely
// why a remote-only snapshot cannot be named.
@@ -202,11 +213,17 @@ func (e *listEnv) addRemoteRawTimestamp(
data, err := snapshot.EncodeManifest(manifest, 3)
require.NoError(t, err)
err = e.store.Put(context.Background(),
return data
}
// putRemoteManifest stores an encoded manifest on the destination store
// under remoteKey.
func (e *listEnv) putRemoteManifest(t *testing.T, remoteKey string, data []byte) {
t.Helper()
err := e.store.Put(context.Background(),
"metadata/"+remoteKey+"/manifest.json.zst", bytes.NewReader(data))
require.NoError(t, err)
return remoteKey
}
// Fixtures shared across the listing tests.
@@ -345,8 +362,9 @@ func TestSnapshotListAndPurgePrintSizesLikeRemoteInfo(t *testing.T) {
// Without --force and with nothing on stdin the purge lists what it
// would delete, reads no confirmation, and deletes nothing.
env.stdout.Reset()
require.NoError(t, env.v.PurgeSnapshotsWithOptions(
&vaultik.SnapshotPurgeOptions{OlderThan: "1d"}))
require.ErrorIs(t, env.v.PurgeSnapshotsWithOptions(
&vaultik.SnapshotPurgeOptions{OlderThan: "1d"}),
vaultik.ErrNotConfirmed)
assert.Contains(t, findTableRow(t, env.stdout.String(), listLocalID),
wantSize, "snapshot purge")
}
@@ -791,9 +809,17 @@ func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)
// One past the cap, so exactly one snapshot is omitted.
// One past the cap, so exactly one snapshot is omitted. The listing
// names a snapshot by the key its manifest is stored under, never by
// the manifest's own ID. So all of them share one encoded manifest:
// encoding 1001 took seconds.
data := encodeRemoteManifest(t,
snapshot.RemoteSnapshotKey("otherhost_bulk"),
timestamp.Format(time.RFC3339))
for i := range maxRemoteOnlyRowsForTest + 1 {
env.addRemote(t, fmt.Sprintf("otherhost_bulk_%04d", i), timestamp)
env.putRemoteManifest(t, snapshot.RemoteSnapshotKey(
fmt.Sprintf("otherhost_bulk_%04d", i)), data)
}
stderr := captureProcessStderr(t, func() {
+7
View File
@@ -5,6 +5,7 @@ package vaultik
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
@@ -184,6 +185,12 @@ func (v *Vaultik) printlnStdout(args ...any) {
_, _ = fmt.Fprintln(v.Stdout, args...)
}
// ErrNotConfirmed is returned when a confirmation prompt gets any answer
// but the one it asks for (`y` or `Y`, or `yes` for `database delete`), or
// stdin ends first. Nothing the prompt asked about is deleted, and the
// command exits non-zero.
var ErrNotConfirmed = errors.New("deletion not confirmed")
// scanStdin reads a line of input from stdin.
func (v *Vaultik) scanStdin(a ...any) (int, error) {
return fmt.Fscanln(v.Stdin, a...)