Add snapshot files to list a snapshot's files without restoring it (closes #435)
check / check (push) Successful in 12m11s

`vaultik snapshot files <snapshot-id> [path] [--json]` downloads and
decrypts the snapshot database, as restore does, and prints mode, size,
modification time and path for every file at or under the path. A path
that matches nothing is an error. It needs the private key. The mode is
in Go's file-mode notation, which the README spells out.

Named `files` because `ls` is already the alias of `snapshot list`.
The missing-key error no longer says "for restore", since this command
and deep verify return it too. The README opening now states the
private-key rule first.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-13 20:33:36 +00:00
1 parent 5ca0d46932
commit 600f3a9592
8 files changed
+457 -41

No files matched your search

+6 -5
View File
@@ -38,10 +38,11 @@ const (
// prune, remote nuke) write the local index or the remote store. They
// hold the PID lock so that at most one runs at a time.
mutating lockMode = iota
// readOnly commands (info, snapshot list, snapshot verify, remote info,
// snapshot restore) do not write the local index or the remote store,
// so they run without the lock and are never blocked by a running
// mutator. restore writes only to the target directory it is given.
// readOnly commands (info, snapshot list, snapshot files, snapshot
// verify, remote info, snapshot restore) do not write the local index
// or the remote store, so they run without the lock and are never
// blocked by a running mutator. restore writes only to the target
// directory it is given.
readOnly
)
@@ -310,7 +311,7 @@ func RunOperation(
}
// runVaultikApp runs the standard single-operation command lifecycle
// shared by the snapshot list/purge/remove and remote nuke subcommands:
// shared by the snapshot list/files/purge/remove and remote nuke subcommands:
// resolve the config, then run op against the Vaultik instance through
// RunOperation, reporting a failure prefixed with failMsg on stderr. mode
// says whether the command takes the PID lock. jsonOutput marks a command
+47
View File
@@ -0,0 +1,47 @@
package cli //nolint:testpackage // shares runEntry and the backup config helpers
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// cmdFiles is the word of `snapshot files`, used to build argument
// vectors below.
const cmdFiles = "files"
// TestEntrySnapshotFilesListsOnlyThePath backs up two files and runs
// `snapshot files` with the snapshot ID and the path of one of them,
// which must be the only file listed.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotFilesListsOnlyThePath(t *testing.T) {
configPath, _ := writeBackupConfigWithPrivateKey(t)
// writeBackupConfig puts file.txt in the source directory beside the
// config file.
sourceDir := filepath.Join(filepath.Dir(configPath), "source")
listed := filepath.Join(sourceDir, "file.txt")
require.NoError(t, os.WriteFile(
filepath.Join(sourceDir, "other.txt"), []byte("other"), 0o600))
code, _, stderr := runEntry(t,
flagConfig, configPath, cmdSnapshot, cmdCreate)
require.Equal(t, 0, code, stderr)
code, stdout, stderr := runEntry(t, flagConfig, configPath, cmdSnapshot,
cmdFiles, onlySnapshotID(t, configPath), listed, flagJSON)
require.Equal(t, 0, code, stderr)
var entries []vaultik.ListedFile
require.NoError(t, json.Unmarshal([]byte(stdout), &entries))
require.Len(t, entries, 1, "only the path given may be listed")
assert.Equal(t, listed, entries[0].Path)
}
+1 -1
View File
@@ -48,7 +48,7 @@ func TestCLIEntry(t *testing.T) {
} else {
// Check snapshot subcommands
expectedSubCommands := []string{
"create", "list", "purge", "verify", "remove", "restore",
"create", "list", "files", "purge", "verify", "remove", "restore",
}
for _, expected := range expectedSubCommands {
found := false
+43
View File
@@ -9,6 +9,10 @@ import (
"sneak.berlin/go/vaultik/internal/vaultik"
)
// filesMaxArgs is the maximum positional argument count of
// `snapshot files <snapshot-id> [path]`.
const filesMaxArgs = 2
var (
errSnapshotIDRequired = errors.New("snapshot ID required")
errWrongArgCount = errors.New("wrong argument count")
@@ -52,6 +56,7 @@ func NewSnapshotCommand() *cobra.Command {
// Add subcommands
cmd.AddCommand(newSnapshotCreateCommand())
cmd.AddCommand(newSnapshotListCommand())
cmd.AddCommand(newSnapshotFilesCommand())
cmd.AddCommand(newSnapshotPurgeCommand())
cmd.AddCommand(newSnapshotVerifyCommand())
cmd.AddCommand(newSnapshotRemoveCommand())
@@ -172,6 +177,44 @@ func newSnapshotListCommand() *cobra.Command {
return cmd
}
// newSnapshotFilesCommand creates the 'snapshot files' subcommand
func newSnapshotFilesCommand() *cobra.Command {
var jsonOutput bool
cmd := &cobra.Command{
Use: "files <snapshot-id> [path]",
Short: "List the files in a snapshot",
Long: `Lists the path, size, mode and modification time of every file in a
snapshot, without restoring it. With a path, lists only that path and
what is under it; a path that matches nothing is an error.
The snapshot may be named by its ID or, on a host with no local index,
by the remote key that 'snapshot list' prints for a remote-only snapshot
(an unambiguous leading part is enough).
The file list is only in the snapshot's encrypted database, so this
downloads and decrypts it, and requires the age private key in the
VAULTIK_AGE_SECRET_KEY environment variable, as 'snapshot restore' does.`,
Args: cobra.RangeArgs(1, filesMaxArgs),
RunE: func(cmd *cobra.Command, args []string) error {
path := ""
if len(args) == filesMaxArgs {
path = args[1]
}
return runVaultikApp(cmd, readOnly, jsonOutput,
"Failed to list snapshot files",
func(v *vaultik.Vaultik) error {
return v.ListSnapshotFiles(args[0], path, jsonOutput)
})
},
}
cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output in JSON format")
return cmd
}
// newSnapshotPurgeCommand creates the 'snapshot purge' subcommand
func newSnapshotPurgeCommand() *cobra.Command {
opts := &vaultik.SnapshotPurgeOptions{}
+17 -13
View File
@@ -31,7 +31,7 @@ var (
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" +
"decryption key required\n\n" +
"Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
"age private key file:\n" +
" export VAULTIK_AGE_SECRET_KEY=\"$(cat vaultik_backup_private_key.txt)\"")
@@ -153,18 +153,7 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
return fmt.Errorf("downloading snapshot database: %w", err)
}
defer func() {
err := tempDB.Close()
if err != nil {
log.Debug("Failed to close temp database", "error", err)
}
// Remove the whole private directory, so the decrypted database
// and any SQLite side files it produced are gone on every path.
err = v.Fs.RemoveAll(tempDir)
if err != nil {
log.Debug("Failed to remove temp database directory", "error", err)
}
}()
defer v.closeSnapshotDB(tempDB, tempDir)
repos := database.NewRepositories(tempDB)
@@ -880,6 +869,21 @@ func (v *Vaultik) downloadSnapshotDB(
return db, tempDir, nil
}
// closeSnapshotDB closes a database returned by downloadSnapshotDB and
// removes its private directory, so the decrypted database and any SQLite
// side files it produced are gone.
func (v *Vaultik) closeSnapshotDB(db *database.DB, tempDir string) {
err := db.Close()
if err != nil {
log.Debug("Failed to close temp database", "error", err)
}
err = v.Fs.RemoveAll(tempDir)
if err != nil {
log.Debug("Failed to remove temp database directory", "error", err)
}
}
// verifySnapshotDBIdentity confirms the decrypted metadata database really
// is the snapshot named by remoteKey. age decryption proves the database
// is readable, not that the object served at
+114
View File
@@ -0,0 +1,114 @@
package vaultik
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"text/tabwriter"
"time"
"sneak.berlin/go/vaultik/internal/database"
)
// ListedFile is one entry printed by `snapshot files`. Mode is the Go
// file mode string, which shows the entry type and the permission bits
// (for example -rw-r--r-- or drwxr-xr-x).
type ListedFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
Mode string `json:"mode"`
MTime time.Time `json:"mtime"`
}
// ListSnapshotFiles prints the path, size, mode and modification time of
// every file the snapshot holds at or under path, or of every file when
// path is empty. The file list is only in the snapshot's encrypted
// database, so this downloads and decrypts it as restore does, and needs
// the private key.
func (v *Vaultik) ListSnapshotFiles(snapshotID, path string, jsonOutput bool) error {
identities, err := v.restoreIdentities()
if err != nil {
return err
}
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID)
if err != nil {
return err
}
db, tempDir, err := v.downloadSnapshotDB(snapshotID, remoteKey, identities)
if errors.Is(err, errWrongPrivateKey) {
return err
}
if err != nil {
return fmt.Errorf("downloading snapshot database: %w", err)
}
defer v.closeSnapshotDB(db, tempDir)
repos := database.NewRepositories(db)
var files []*database.File
if path == "" {
files, err = repos.Files.ListAll(v.ctx)
} else {
files, err = repos.Files.ListUnderPath(v.ctx, filepath.Clean(path))
}
if err != nil {
return fmt.Errorf("listing files: %w", err)
}
if path != "" && len(files) == 0 {
return fmt.Errorf("%w: %s", errPathsNotInSnapshot, path)
}
entries := make([]ListedFile, 0, len(files))
for _, file := range files {
entries = append(entries, ListedFile{
Path: file.Path.String(),
Size: file.Size,
Mode: os.FileMode(file.Mode).String(),
MTime: file.MTime,
})
}
if jsonOutput {
encoder := json.NewEncoder(v.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(entries)
}
if v.UI.Quiet() {
return nil
}
return v.printListedFileTable(entries)
}
// printListedFileTable writes the entries as a plain table to stdout.
func (v *Vaultik) printListedFileTable(entries []ListedFile) error {
w := tabwriter.NewWriter(v.Stdout, 0, 0, tabPadding, ' ', 0)
_, err := fmt.Fprintln(w, "MODE\tSIZE\tMODIFIED\tPATH")
if err != nil {
return err
}
for _, entry := range entries {
_, err = fmt.Fprintf(w, "%s\t%d\t%s\t%s\n",
entry.Mode, entry.Size,
entry.MTime.Format(time.RFC3339), entry.Path)
if err != nil {
return err
}
}
return w.Flush()
}
+164
View File
@@ -0,0 +1,164 @@
package vaultik_test
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"strings"
"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"
)
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/435:
// `snapshot files` lists what a snapshot holds without restoring it.
// backUpForFileListing backs up the fault source tree and returns a
// reader whose stdout is out, the snapshot ID, and the source directory.
func backUpForFileListing(
t *testing.T, out *bytes.Buffer,
) (*vaultik.Vaultik, string, string) {
t.Helper()
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background()
cfg := faultTestConfig()
writeFaultSourceTree(t, fs, dataDir)
store, err := storage.NewFileStorer(t.TempDir())
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, "files")
require.NoError(t, db.Close())
v := newReaderVaultik(ctx, cfg, store, nil, fs)
v.Stdout = out
return v, id, dataDir
}
func TestSnapshotFilesJSONListsEveryFileWithItsMetadata(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
var out bytes.Buffer
v, id, dataDir := backUpForFileListing(t, &out)
require.NoError(t, v.ListSnapshotFiles(id, "", true))
var entries []vaultik.ListedFile
require.NoError(t, json.Unmarshal(out.Bytes(), &entries))
byPath := make(map[string]vaultik.ListedFile, len(entries))
for _, entry := range entries {
byPath[entry.Path] = entry
}
for _, rel := range []string{"small.txt", "a.bin", "sub", "sub/b.bin", "sub/c.bin"} {
path := filepath.Join(dataDir, rel)
entry, ok := byPath[path]
require.True(t, ok, "%s must be listed", path)
info, err := afero.NewOsFs().Stat(path)
require.NoError(t, err)
assert.Equal(t, info.Mode().String(), entry.Mode, path)
assert.True(t, info.ModTime().Equal(entry.MTime),
"%s: mtime %v, listed %v", path, info.ModTime(), entry.MTime)
if !info.IsDir() {
assert.Equal(t, info.Size(), entry.Size, path)
}
}
}
func TestSnapshotFilesListsOnlyWhatIsUnderThePath(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
var out bytes.Buffer
v, id, dataDir := backUpForFileListing(t, &out)
require.NoError(t, v.ListSnapshotFiles(id, filepath.Join(dataDir, "sub"), false))
text := out.String()
assert.Contains(t, text, filepath.Join(dataDir, "sub", "b.bin"))
assert.Contains(t, text, filepath.Join(dataDir, "sub", "c.bin"))
assert.NotContains(t, text, filepath.Join(dataDir, "small.txt"))
assert.True(t, strings.HasPrefix(text, "MODE"), "the table must have a header")
}
// TestSnapshotFilesQuietSuppressesTableNotJSON is the --quiet contract
// for `snapshot files`: the table is silenced, but the --json document a
// script depends on is still written.
func TestSnapshotFilesQuietSuppressesTableNotJSON(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
var out bytes.Buffer
v, id, _ := backUpForFileListing(t, &out)
v.UI.SetQuiet(true)
require.NoError(t, v.ListSnapshotFiles(id, "", false))
require.Empty(t, out.String(), "the table must be silenced under --quiet")
require.NoError(t, v.ListSnapshotFiles(id, "", true))
var entries []vaultik.ListedFile
require.NoError(t, json.Unmarshal(out.Bytes(), &entries))
assert.NotEmpty(t, entries,
"the --json document must still be written under --quiet")
}
func TestSnapshotFilesFailsOnAPathThatMatchesNothing(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
var out bytes.Buffer
v, id, dataDir := backUpForFileListing(t, &out)
missing := filepath.Join(dataDir, "typo.txt")
err := v.ListSnapshotFiles(id, missing, false)
require.Error(t, err)
assert.Contains(t, err.Error(), missing, "the error must name the path")
assert.Empty(t, out.String(), "nothing must be printed")
}
func TestSnapshotFilesRequiresThePrivateKey(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
var out bytes.Buffer
v, id, _ := backUpForFileListing(t, &out)
v.Config.AgeSecretKey = ""
err := v.ListSnapshotFiles(id, "", false)
require.Error(t, err)
assert.Contains(t, err.Error(), "VAULTIK_AGE_SECRET_KEY")
}