Author SHA1 Message Date
sneak 1e01457d5d Add snapshot files to list a snapshot's files without restoring it (closes #435)
check / check (push) Waiting to run
`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.

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.

Model: opus-5-5
2026-11-13 18:48:34 +00:00
clawbot 5ca0d46932 Refuse an exclude pattern that does not compile when the config loads (closes #455)
check / check (push) Canceled after 0s
An exclude pattern that did not compile was skipped with a log warning during the backup, which then stored what it meant to leave out. Config.Validate now refuses such a pattern, global or per snapshot, naming the setting and quoting the pattern. The compile step moves to a new package, internal/exclude, which both the config and the scanner call.

Model: opus-5-5
2026-11-11 10:58:54 +01:00
11 changed files with 643 additions and 100 deletions

No files matched your search

+30 -18
View File
@@ -171,6 +171,7 @@ vaultik [--config <path>] config get <key>
vaultik [--config <path>] config set <key> <value>
vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than <duration>]
vaultik [--config <path>] snapshot list [--json] # alias: ls
vaultik [--config <path>] snapshot files <snapshot-id> [path] [--json]
vaultik [--config <path>] snapshot verify <snapshot-id> [--deep] [--json]
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
vaultik [--config <path>] snapshot remove <snapshot-id> [--dry-run] [--force] [--local-only] [--json] # alias: rm
@@ -223,12 +224,12 @@ the backup can delete it; the new snapshot then cannot be restored.
`remote nuke` deletes everything regardless. To not depend on the grace period,
give each host or user its own prefix (or directory) on the destination.
Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
`remote info` — do not take the lock and are never blocked, so they run even
while a backup is in progress. `snapshot restore` does not take the lock either:
it writes only to the target directory you name, not the local index or the
destination store. `config`, `database delete`, `completion`, and `version` do
not take the lock.
Read-only commands — `info`, `snapshot list`, `snapshot files`,
`snapshot verify`, and `remote info` — do not take the lock and are never
blocked, so they run even while a backup is in progress. `snapshot restore` does
not take the lock either: it writes only to the target directory you name, not
the local index or the destination store. `config`, `database delete`,
`completion`, and `version` do not take the lock.
### stdout and stderr
@@ -261,9 +262,9 @@ work as written.
### environment variables
- `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for
`snapshot restore` and `snapshot verify --deep`). May hold the whole
`age-keygen` file — comments and every identity in it are accepted. Set it
from the file, e.g.
`snapshot restore`, `snapshot files` and `snapshot verify --deep`). May hold
the whole `age-keygen` file — comments and every identity in it are accepted.
Set it from the file, e.g.
`export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"`, so
the key is not typed into your shell history.
- `VAULTIK_CONFIG`: Path to config file (overridden by `--config`)
@@ -408,6 +409,17 @@ point behind.
truncated listing all go to stderr through the logger, so stdout stays a
single parseable document.
**`snapshot files`**: List the files in a snapshot without restoring it. Each
row of the table shows mode, size in bytes, modification time and path, sorted
by path.
- Requires `VAULTIK_AGE_SECRET_KEY`: the file list is only in the snapshot's
encrypted database, which the command downloads and decrypts.
- Accepts the same identifiers as `snapshot restore`.
- An optional path limits the list to that path and what is under it. A path
that matches nothing in the snapshot is an error.
- `--json`: Output an array of objects with `path`, `size`, `mode` and `mtime`.
**`snapshot verify`**: Verify snapshot integrity.
- Default (shallow): checks that every blob the manifest lists is present in
@@ -722,7 +734,7 @@ annotated example also lives in
| Field | Default | Description |
| -------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `age_recipients` | (required by `snapshot create`) | Age public keys for encryption. Other commands run without one, so a machine that only restores can leave it empty |
| `age_secret_key` | (unset) | Age private key for decryption (`snapshot restore`, `snapshot verify --deep`). Setting it in the config file places the private key on the backed-up host, defeating the public-key-only design (see "why" above). Prefer the `VAULTIK_AGE_SECRET_KEY` environment variable, supplied only on the machine you restore from. |
| `age_secret_key` | (unset) | Age private key for decryption (`snapshot restore`, `snapshot files`, `snapshot verify --deep`). Setting it in the config file places the private key on the backed-up host, defeating the public-key-only design (see "why" above). Prefer the `VAULTIK_AGE_SECRET_KEY` environment variable, supplied only on the machine you restore from. |
| `snapshots` | (required) | Named snapshot definitions with paths and excludes |
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) |
@@ -840,14 +852,14 @@ environment variable is unset (https://no-color.org/).
- **The data a command exists to produce** is written plain, with no marker and
no color, because a marker would corrupt a table or a parsed document. This
covers the `version`, `info`, `remote info` and `snapshot verify` reports, the
`snapshot list` table, `config get` values, and every `--json` document.
`--quiet` silences the human reports and tables (`version`, `info`,
`remote info`, `snapshot verify`, `snapshot list`) but never the
machine-consumed `config get` value or the `--json` documents, which a script
depends on. The confirmation prompts of `database delete`, `prune`,
`snapshot purge` and `snapshot remove`, each with what it asks to delete, are
also written this way and always shown: they are an interactive exchange the
operator must see.
`snapshot list` and `snapshot files` tables, `config get` values, and every
`--json` document. `--quiet` silences the human reports and tables (`version`,
`info`, `remote info`, `snapshot verify`, `snapshot list`, `snapshot files`)
but never the machine-consumed `config get` value or the `--json` documents,
which a script depends on. The confirmation prompts of `database delete`,
`prune`, `snapshot purge` and `snapshot remove`, each with what it asks to
delete, are also written this way and always shown: they are an interactive
exchange the operator must see.
`internal/ui` writes to stdout; it is the output the user asked for. The
exceptions are the startup banner and the error a failed command ends with,
+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{}
+25 -7
View File
@@ -396,6 +396,8 @@ func Parse(data []byte) (*Config, error) {
// never the value. An empty list is accepted, because only snapshot
// create needs a recipient and it checks for one itself
// - At least one snapshot must be configured with at least one path
// - Every exclude pattern, global or in a snapshot, must compile; errors
// name the setting and quote the pattern
// - Storage must be configured (either storage_url or s3.* fields)
// - The index path must be absolute: a relative one would resolve against
// the working directory, so each directory would get its own index
@@ -416,18 +418,18 @@ func (c *Config) Validate() error {
}
}
if len(c.Snapshots) == 0 {
return errNoSnapshots
err := c.validateSnapshots()
if err != nil {
return err
}
for name, snap := range c.Snapshots {
if len(snap.Paths) == 0 {
return fmt.Errorf("%w: %q", errSnapshotNoPaths, name)
}
err = c.validateExcludes()
if err != nil {
return err
}
// Validate storage configuration
err := c.validateStorage()
err = c.validateStorage()
if err != nil {
return err
}
@@ -466,6 +468,22 @@ func (c *Config) Validate() error {
return nil
}
// validateSnapshots refuses a config with no snapshot or with a snapshot
// that has no path.
func (c *Config) validateSnapshots() error {
if len(c.Snapshots) == 0 {
return errNoSnapshots
}
for name, snap := range c.Snapshots {
if len(snap.Paths) == 0 {
return fmt.Errorf("%w: %q", errSnapshotNoPaths, name)
}
}
return nil
}
// validateAgeRecipient parses one age_recipients entry with the age library
// and returns a value-free error on failure. A recipient string can be
// sensitive (an operator may paste a secret key by mistake), so neither the
+39
View File
@@ -0,0 +1,39 @@
package config
import (
"fmt"
"sneak.berlin/go/vaultik/internal/exclude"
)
// validateExcludes refuses an exclude pattern, global or in a snapshot,
// that does not compile, naming the setting and quoting the pattern.
func (c *Config) validateExcludes() error {
err := validateExcludeList("exclude", c.Exclude)
if err != nil {
return err
}
for _, name := range c.SnapshotNames() {
err = validateExcludeList("snapshots."+name+".exclude",
c.Snapshots[name].Exclude)
if err != nil {
return err
}
}
return nil
}
// validateExcludeList refuses the first pattern in patterns that does not
// compile. setting is the name of the list, as in "exclude".
func validateExcludeList(setting string, patterns []string) error {
for i, pattern := range patterns {
_, _, err := exclude.Compile(pattern)
if err != nil {
return fmt.Errorf("%s[%d]: %w", setting, i, err)
}
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package config_test
import (
"os"
"path/filepath"
"strings"
"testing"
"sneak.berlin/go/vaultik/internal/config"
)
// TestLoadRefusesBadExcludePattern checks that an exclude pattern that does
// not compile, global or in a snapshot, fails at load with the setting and
// the pattern named, rather than the backup storing what it meant to leave
// out.
func TestLoadRefusesBadExcludePattern(t *testing.T) {
t.Parallel()
const storage = "storage_url: file:///tmp/vaultik-test-storage\n"
tests := []struct {
name string
config string
wantErr string
}{
{
name: "global",
config: "exclude: ['*.tmp', '.cache', 'build[']\n" +
"snapshots:\n" +
" home:\n" +
" paths: [/tmp/vaultik-test-source]\n" + storage,
wantErr: `exclude[2]: "build[" is not a valid exclude pattern`,
},
{
name: "snapshot",
config: "snapshots:\n" +
" home:\n" +
" paths: [/tmp/vaultik-test-source]\n" +
" exclude: ['/a/**/x[']\n" + storage,
wantErr: `snapshots.home.exclude[0]: "/a/**/x[" is not a valid exclude pattern`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(tt.config), 0o600)
if err != nil {
t.Fatalf("write config: %v", err)
}
_, err = config.Load(path)
if err == nil {
t.Fatalf("Load() returned nil, want an error containing %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Load() error = %q, want it to contain %q", err, tt.wantErr)
}
})
}
}
+77
View File
@@ -0,0 +1,77 @@
// Package exclude compiles exclude patterns into globs.
package exclude
import (
"errors"
"fmt"
"strings"
"github.com/gobwas/glob"
)
var errBadExcludePattern = errors.New("is not a valid exclude pattern")
// Compile compiles one exclude pattern into the globs that match it.
// anchored reports whether the pattern matches only from the root of the
// source directory. An empty pattern yields no globs. Config.Validate and
// the scanner both compile patterns with it, so a pattern that loads is one
// the scanner can use.
func Compile(pattern string) ([]glob.Glob, bool, error) {
if pattern == "" {
return nil, false, nil
}
// A leading "/" anchors the pattern to the source directory.
trimmed, anchored := strings.CutPrefix(pattern, "/")
// A trailing "/" marks a directory and is not matched.
trimmed = strings.TrimSuffix(trimmed, "/")
// A leading "**/" demands a "/" before the name and so misses an
// entry directly under the source path. It means "at every depth",
// which is what an unanchored pattern already does, so drop the
// prefix and match unanchored: as in gitignore, "**/x" and
// "/**/x" then also match a top-level "x".
if rest, found := strings.CutPrefix(trimmed, "**/"); found {
trimmed = rest
anchored = false
}
// Likewise "/**/" inside a pattern demands two slashes, so
// "a/**/*.log" misses "a/x.log". As in gitignore it stands for
// zero or more directories, so also compile the pattern with it
// made a single "/". The glob library's {a,b} is no help: with a
// "**" inside it in mid-pattern, it misses paths it should match.
var globs []glob.Glob
for _, variant := range doubleStarVariants(trimmed) {
g, err := glob.Compile(variant, '/')
if err != nil {
return nil, false, fmt.Errorf("%q %w: %w",
pattern, errBadExcludePattern, err)
}
globs = append(globs, g)
}
return globs, anchored, nil
}
// doubleStarVariants returns pattern once for every way of keeping each
// "/**/" in it or making it a single "/".
func doubleStarVariants(pattern string) []string {
const doubleStar = "/**/"
before, after, found := strings.Cut(pattern, doubleStar)
if !found {
return []string{pattern}
}
var variants []string
for _, rest := range doubleStarVariants(after) {
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
}
return variants
}
+73
View File
@@ -0,0 +1,73 @@
package exclude_test
import (
"slices"
"strings"
"testing"
"github.com/gobwas/glob"
"sneak.berlin/go/vaultik/internal/exclude"
)
// TestCompile checks the error, the anchoring and the globs that Compile
// returns for one exclude pattern.
func TestCompile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pattern string
wantErr string
wantAnchored bool
// wantMatches lists paths that one of the globs must match. When
// it is empty, Compile must return no globs.
wantMatches []string
}{
{name: "does not compile", pattern: "build[",
wantErr: `"build[" is not a valid exclude pattern`},
{name: "empty", pattern: ""},
{name: "leading slash anchors", pattern: "/build",
wantAnchored: true, wantMatches: []string{"build"}},
{name: "double star after the slash clears anchoring",
pattern: "/**/build", wantMatches: []string{"build"}},
{name: "double star in the middle", pattern: "a/**/*.log",
wantMatches: []string{"a/x.log", "a/b/x.log"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
globs, anchored, err := exclude.Compile(tt.pattern)
if tt.wantErr == "" && err != nil {
t.Fatalf("Compile(%q) error = %v, want none", tt.pattern, err)
}
if tt.wantErr != "" &&
(err == nil || !strings.HasPrefix(err.Error(), tt.wantErr)) {
t.Fatalf("Compile(%q) error = %v, want one starting with %q",
tt.pattern, err, tt.wantErr)
}
if anchored != tt.wantAnchored {
t.Errorf("Compile(%q) anchored = %t, want %t",
tt.pattern, anchored, tt.wantAnchored)
}
if len(tt.wantMatches) == 0 && len(globs) != 0 {
t.Errorf("Compile(%q) returned %d globs, want none",
tt.pattern, len(globs))
}
for _, path := range tt.wantMatches {
matched := slices.ContainsFunc(globs, func(g glob.Glob) bool {
return g.Match(path)
})
if !matched {
t.Errorf("Compile(%q) globs do not match %q",
tt.pattern, path)
}
}
})
}
}
+15 -62
View File
@@ -20,6 +20,7 @@ import (
"sneak.berlin/go/vaultik/internal/blob"
"sneak.berlin/go/vaultik/internal/chunker"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/exclude"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/types"
@@ -221,8 +222,12 @@ func NewScanner(cfg ScannerConfig) *Scanner {
progress = NewProgressReporter()
}
// Compile exclude patterns
compiledExclude := compileExcludePatterns(cfg.Exclude)
compiledExclude, err := compileExcludePatterns(cfg.Exclude)
if err != nil {
log.Error("Failed to compile exclude patterns", "error", err)
return nil
}
uiw := cfg.UI
if uiw == nil {
@@ -2318,51 +2323,18 @@ func wrapPermissionError(path string, err error) error {
"with --skip-errors to continue past unreadable files)", path, err)
}
// compileExcludePatterns compiles the exclude patterns into glob matchers
func compileExcludePatterns(patterns []string) []compiledPattern {
// compileExcludePatterns compiles the exclude patterns into glob matchers.
// Config.Validate has already refused a pattern that does not compile.
func compileExcludePatterns(patterns []string) ([]compiledPattern, error) {
var compiled []compiledPattern
for _, p := range patterns {
if p == "" {
continue
globs, anchored, err := exclude.Compile(p)
if err != nil {
return nil, err
}
// Check if pattern is anchored (starts with /)
anchored := strings.HasPrefix(p, "/")
pattern := p
if anchored {
pattern = p[1:] // Remove leading /
}
// Remove trailing slash if present (directory indicator)
pattern = strings.TrimSuffix(pattern, "/")
// A leading "**/" demands a "/" before the name and so misses an
// entry directly under the source path. It means "at every depth",
// which is what an unanchored pattern already does, so drop the
// prefix and match unanchored: as in gitignore, "**/x" and
// "/**/x" then also match a top-level "x".
if rest, found := strings.CutPrefix(pattern, "**/"); found {
pattern = rest
anchored = false
}
// Likewise "/**/" inside a pattern demands two slashes, so
// "a/**/*.log" misses "a/x.log". As in gitignore it stands for
// zero or more directories, so also compile the pattern with it
// made a single "/". The glob library's {a,b} is no help: with a
// "**" inside it in mid-pattern, it misses paths it should match.
for _, variant := range doubleStarVariants(pattern) {
// For patterns without path separators, we need to match them as components
// e.g., ".git" should match ".git" anywhere in the path
g, err := glob.Compile(variant, '/')
if err != nil {
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
break
}
for _, g := range globs {
compiled = append(compiled, compiledPattern{
pattern: g,
anchored: anchored,
@@ -2371,26 +2343,7 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
}
}
return compiled
}
// doubleStarVariants returns pattern once for every way of keeping each
// "/**/" in it or making it a single "/".
func doubleStarVariants(pattern string) []string {
const doubleStar = "/**/"
before, after, found := strings.Cut(pattern, doubleStar)
if !found {
return []string{pattern}
}
var variants []string
for _, rest := range doubleStarVariants(after) {
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
}
return variants
return compiled, nil
}
// shouldExclude checks if a path should be excluded based on exclude patterns
+2 -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)
+134
View File
@@ -0,0 +1,134 @@
package vaultik
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"text/tabwriter"
"time"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
)
// errPathNotInSnapshot is returned when the path given to `snapshot files`
// matches nothing in the snapshot.
var errPathNotInSnapshot = errors.New("path matches nothing in the snapshot")
// SnapshotFile 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 SnapshotFile 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", errPathNotInSnapshot, path)
}
entries := make([]SnapshotFile, 0, len(files))
for _, file := range files {
entries = append(entries, SnapshotFile{
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.printSnapshotFileTable(entries)
}
// printSnapshotFileTable writes the entries as a plain table to stdout.
func (v *Vaultik) printSnapshotFileTable(entries []SnapshotFile) 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()
}
// 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)
}
}
+140
View File
@@ -0,0 +1,140 @@
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.SnapshotFile
require.NoError(t, json.Unmarshal(out.Bytes(), &entries))
byPath := make(map[string]vaultik.SnapshotFile, 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")
}
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")
}