From 6ed84bf6d89b6a1885db252070ab5d63db17554e Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 13 Oct 2026 05:33:46 +0000 Subject: [PATCH] Fail restore for a requested path that matches nothing (closes #309) snapshot restore said nothing about a requested path that matched no entry in the snapshot (a typo, a relative path, or a path through a configured snapshot path that is a symlink, whose entries are stored under the resolved directory) and exited 0, also when nothing matched at all. Each such path is now named in a warning, and restore returns an error after restoring the paths that do match. Making a path through a symlink match is not part of this change. Judgement call: when files also fail to restore, that error is the one returned; the unmatched paths are still named. Model: opus-5-5 --- README.md | 4 +- internal/cli/snapshot_restore.go | 2 + internal/vaultik/restore.go | 58 +++++-- .../vaultik/restore_unmatched_path_test.go | 142 ++++++++++++++++++ 4 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 internal/vaultik/restore_unmatched_path_test.go diff --git a/README.md b/README.md index 306b27d..52a89d0 100644 --- a/README.md +++ b/README.md @@ -372,7 +372,9 @@ on the destination in one go, use `vaultik remote nuke --force`. store — its remote key (or an unambiguous leading part of it) as shown by `snapshot list`. See [restoring on another machine](#restoring-on-another-machine). -* Optional path arguments to restore specific files/directories (default: all) +* Optional path arguments to restore specific files/directories (default: all). + A path that matches nothing in the snapshot is named in a warning, and the + command exits non-zero after restoring the paths that match * Preserves file permissions, timestamps, ownership (ownership requires root), symlinks, and empty directories * `--verify`: After restoring, verify every file's chunk hashes match diff --git a/internal/cli/snapshot_restore.go b/internal/cli/snapshot_restore.go index ce28542..8609b90 100644 --- a/internal/cli/snapshot_restore.go +++ b/internal/cli/snapshot_restore.go @@ -30,6 +30,8 @@ This command will restore files from the specified snapshot to the target directory. If no paths are specified, all files are restored. If paths are specified, only matching files/directories are restored. +A path that matches nothing in the snapshot is named in a warning, and +the command exits non-zero after restoring the paths that match. The snapshot may be named by its ID or, when restoring on a host with no local index, by the remote key that 'snapshot list' prints for a diff --git a/internal/vaultik/restore.go b/internal/vaultik/restore.go index c5843e8..117e73b 100644 --- a/internal/vaultik/restore.go +++ b/internal/vaultik/restore.go @@ -27,6 +27,7 @@ import ( // Sentinel errors for restore failures. var ( errFilesFailedRestore = errors.New("file(s) failed to restore") + errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot") errFilesFailedVerify = errors.New("files failed verification") errDecryptionKeyRequired = errors.New( "decryption key required for restore\n\n" + @@ -149,7 +150,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error { repos := database.NewRepositories(tempDB) // Step 2: Get list of files to restore - files, err := v.getFilesToRestore(v.ctx, repos, opts.Paths) + files, unmatched, err := v.getFilesToRestore(v.ctx, repos, opts.Paths) if err != nil { return fmt.Errorf("getting files to restore: %w", err) } @@ -158,7 +159,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error { log.Warn("No files found to restore") v.UI.Warningf("No files found to restore.") - return nil + return v.reportUnmatchedPaths(unmatched) } log.Info("Found files to restore", "count", len(files)) @@ -198,14 +199,16 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error { v.UI.Duration(result.Duration), ) - return v.finishRestore(repos, files, opts, result) + return v.finishRestore(repos, files, unmatched, opts, result) } // finishRestore emits the post-restore warnings, runs optional -// verification, and converts any failed-file count into an error. +// verification, and converts any failed-file count or requested path +// that matched nothing into an error. func (v *Vaultik) finishRestore( repos *database.Repositories, files []*database.File, + unmatched []string, opts *RestoreOptions, result *RestoreResult, ) error { @@ -223,6 +226,8 @@ func (v *Vaultik) finishRestore( } } + unmatchedErr := v.reportUnmatchedPaths(unmatched) + // Run verification if requested if opts.Verify { err := v.handleRestoreVerification(repos, files, opts, result) @@ -235,7 +240,23 @@ func (v *Vaultik) finishRestore( return fmt.Errorf("%d %w", result.FilesFailed, errFilesFailedRestore) } - return nil + return unmatchedErr +} + +// reportUnmatchedPaths warns, by name, about each requested path that +// matched nothing in the snapshot, and returns an error counting them, +// or nil when there are none. +func (v *Vaultik) reportUnmatchedPaths(unmatched []string) error { + if len(unmatched) == 0 { + return nil + } + + for _, path := range unmatched { + log.Warn("Requested path matches nothing in the snapshot", "path", path) + v.UI.Warningf("%s matches nothing in the snapshot.", v.UI.Path(path)) + } + + return fmt.Errorf("%d %w", len(unmatched), errPathsNotInSnapshot) } // restoreIdentities parses the configured age secret key once into every @@ -812,27 +833,34 @@ func (v *Vaultik) materializeSnapshotDB( return db, tempDir, nil } -// getFilesToRestore returns the list of files to restore based on path filters +// getFilesToRestore returns the list of files to restore based on path +// filters, and the filters, as given, that matched no file. func (v *Vaultik) getFilesToRestore( ctx context.Context, repos *database.Repositories, pathFilters []string, -) ([]*database.File, error) { +) ([]*database.File, []string, error) { // If no filters, get all files if len(pathFilters) == 0 { - return repos.Files.ListAll(ctx) + files, err := repos.Files.ListAll(ctx) + + return files, nil, err } // Get files matching the path filters - var result []*database.File + var ( + result []*database.File + unmatched []string + ) seen := make(map[string]bool) for _, filter := range pathFilters { - // Normalize the filter path - filter = filepath.Clean(filter) - - files, err := repos.Files.ListUnderPath(ctx, filter) + files, err := repos.Files.ListUnderPath(ctx, filepath.Clean(filter)) if err != nil { - return nil, fmt.Errorf("listing files under %s: %w", filter, err) + return nil, nil, fmt.Errorf("listing files under %s: %w", filter, err) + } + + if len(files) == 0 { + unmatched = append(unmatched, filter) } for _, file := range files { @@ -843,7 +871,7 @@ func (v *Vaultik) getFilesToRestore( } } - return result, nil + return result, unmatched, nil } // buildChunkToBlobMap creates a mapping from chunk hash to blob information diff --git a/internal/vaultik/restore_unmatched_path_test.go b/internal/vaultik/restore_unmatched_path_test.go new file mode 100644 index 0000000..69dc1cb --- /dev/null +++ b/internal/vaultik/restore_unmatched_path_test.go @@ -0,0 +1,142 @@ +package vaultik_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sneak.berlin/go/vaultik/internal/config" + "sneak.berlin/go/vaultik/internal/database" + "sneak.berlin/go/vaultik/internal/log" + "sneak.berlin/go/vaultik/internal/storage" + "sneak.berlin/go/vaultik/internal/ui" + "sneak.berlin/go/vaultik/internal/vaultik" +) + +// These tests cover https://git.eeqj.de/sneak/vaultik/issues/309: a path +// given to restore that matches nothing in the snapshot is named in a +// warning and makes the restore fail, after the paths that do match are +// restored. + +func TestRestoreNamesUnmatchedPathAndRestoresTheMatchingOne(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + fs := afero.NewOsFs() + tempDir := t.TempDir() + dataDir := filepath.Join(tempDir, "src") + storeDir := filepath.Join(tempDir, "remote") + restoreDir := filepath.Join(tempDir, "restored") + dbPath := filepath.Join(tempDir, "index.sqlite") + + ctx := context.Background() + cfg := faultTestConfig() + files := writeFaultSourceTree(t, fs, dataDir) + + store, err := storage.NewFileStorer(storeDir) + require.NoError(t, err) + + db, err := database.New(ctx, dbPath) + require.NoError(t, err) + + id := fullFaultBackup(ctx, t, fs, store, cfg, + database.NewRepositories(db), dataDir, dbPath, "unmatched") + require.NoError(t, db.Close()) + + matched := filepath.Join(dataDir, "small.txt") + unmatched := filepath.Join(dataDir, "typo.txt") + + var out bytes.Buffer + + v := newReaderVaultik(ctx, cfg, store, nil, fs) + v.UI = ui.NewWithColor(&out, false) + + err = v.Restore(&vaultik.RestoreOptions{ + SnapshotID: id, + TargetDir: restoreDir, + Paths: []string{matched, unmatched}, + }) + + require.Error(t, err, "a path that matches nothing must fail the restore") + assert.Contains(t, out.String(), unmatched, + "the path that matches nothing must be named") + assert.NotContains(t, out.String(), matched, + "the path that matches must not be reported") + + got, err := afero.ReadFile(fs, filepath.Join(restoreDir, matched)) + require.NoError(t, err, "the path that matches must still be restored") + assert.Equal(t, files[matched], got) +} + +// When no requested path matches, nothing is restored and each path is +// named. One path is relative. The other goes through the symlink that +// is the snapshot's configured path, while the entries are stored under +// the directory the symlink points to. +func TestRestoreNamesEveryPathWhenNothingMatches(t *testing.T) { + log.Initialize(log.Config{}) + t.Parallel() + + const snapshotName = "linked" + + fs := afero.NewOsFs() + tempDir := t.TempDir() + dataDir := filepath.Join(tempDir, "src") + linkDir := filepath.Join(tempDir, "srclink") + storeDir := filepath.Join(tempDir, "remote") + restoreDir := filepath.Join(tempDir, "restored") + dbPath := filepath.Join(tempDir, "index.sqlite") + + ctx := context.Background() + writeFaultSourceTree(t, fs, dataDir) + require.NoError(t, os.Symlink(dataDir, linkDir)) + + cfg := faultTestConfig() + cfg.IndexPath = dbPath + cfg.ChunkSize = config.Size(faultChunkSize) + cfg.Snapshots = map[string]config.SnapshotConfig{ + snapshotName: {Paths: []string{linkDir}}, + } + + store, err := storage.NewFileStorer(storeDir) + require.NoError(t, err) + + db, err := database.New(ctx, dbPath) + require.NoError(t, err) + + repos := database.NewRepositories(db) + v := newBackupVaultik(ctx, cfg, store, repos, db, fs) + + require.NoError(t, backUp(v, snapshotName)) + + id := localSnapshotID(ctx, t, repos, snapshotName) + require.NoError(t, db.Close()) + + throughLink := filepath.Join(linkDir, "sub") + relative := strings.TrimPrefix(filepath.Join(dataDir, "small.txt"), "/") + + var out bytes.Buffer + + reader := newReaderVaultik(ctx, cfg, store, nil, fs) + reader.UI = ui.NewWithColor(&out, false) + + err = reader.Restore(&vaultik.RestoreOptions{ + SnapshotID: id, + TargetDir: restoreDir, + Paths: []string{throughLink, relative}, + }) + + require.Error(t, err, "a restore that matches nothing must fail") + assert.Contains(t, out.String(), throughLink, + "the path through the symlink must be named") + assert.Contains(t, out.String(), relative, + "the relative path must be named") + + _, err = fs.Stat(restoreDir) + assert.True(t, os.IsNotExist(err), "nothing must be restored") +}