Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e01457d5d | ||
|
|
5ca0d46932 |
No files matched your search
@@ -171,6 +171,7 @@ vaultik [--config <path>] config get <key>
|
|||||||
vaultik [--config <path>] config set <key> <value>
|
vaultik [--config <path>] config set <key> <value>
|
||||||
vaultik [--config <path>] snapshot create [snapshot-names...] [--cron] [--prune] [--keep-newer-than <duration>]
|
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 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 verify <snapshot-id> [--deep] [--json]
|
||||||
vaultik [--config <path>] snapshot purge [--keep-latest | --older-than <duration>] [--snapshot <name>...] [--force]
|
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
|
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,
|
`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.
|
give each host or user its own prefix (or directory) on the destination.
|
||||||
|
|
||||||
Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
|
Read-only commands — `info`, `snapshot list`, `snapshot files`,
|
||||||
`remote info` — do not take the lock and are never blocked, so they run even
|
`snapshot verify`, and `remote info` — do not take the lock and are never
|
||||||
while a backup is in progress. `snapshot restore` does not take the lock either:
|
blocked, so they run even while a backup is in progress. `snapshot restore` does
|
||||||
it writes only to the target directory you name, not the local index or the
|
not take the lock either: it writes only to the target directory you name, not
|
||||||
destination store. `config`, `database delete`, `completion`, and `version` do
|
the local index or the destination store. `config`, `database delete`,
|
||||||
not take the lock.
|
`completion`, and `version` do not take the lock.
|
||||||
|
|
||||||
### stdout and stderr
|
### stdout and stderr
|
||||||
|
|
||||||
@@ -261,9 +262,9 @@ work as written.
|
|||||||
### environment variables
|
### environment variables
|
||||||
|
|
||||||
- `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for
|
- `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for
|
||||||
`snapshot restore` and `snapshot verify --deep`). May hold the whole
|
`snapshot restore`, `snapshot files` and `snapshot verify --deep`). May hold
|
||||||
`age-keygen` file — comments and every identity in it are accepted. Set it
|
the whole `age-keygen` file — comments and every identity in it are accepted.
|
||||||
from the file, e.g.
|
Set it from the file, e.g.
|
||||||
`export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"`, so
|
`export VAULTIK_AGE_SECRET_KEY="$(cat vaultik_backup_private_key.txt)"`, so
|
||||||
the key is not typed into your shell history.
|
the key is not typed into your shell history.
|
||||||
- `VAULTIK_CONFIG`: Path to config file (overridden by `--config`)
|
- `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
|
truncated listing all go to stderr through the logger, so stdout stays a
|
||||||
single parseable document.
|
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.
|
**`snapshot verify`**: Verify snapshot integrity.
|
||||||
|
|
||||||
- Default (shallow): checks that every blob the manifest lists is present in
|
- Default (shallow): checks that every blob the manifest lists is present in
|
||||||
@@ -722,7 +734,7 @@ annotated example also lives in
|
|||||||
| Field | Default | Description |
|
| 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_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 |
|
| `snapshots` | (required) | Named snapshot definitions with paths and excludes |
|
||||||
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
|
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
|
||||||
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) |
|
| `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
|
- **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
|
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
|
covers the `version`, `info`, `remote info` and `snapshot verify` reports, the
|
||||||
`snapshot list` table, `config get` values, and every `--json` document.
|
`snapshot list` and `snapshot files` tables, `config get` values, and every
|
||||||
`--quiet` silences the human reports and tables (`version`, `info`,
|
`--json` document. `--quiet` silences the human reports and tables (`version`,
|
||||||
`remote info`, `snapshot verify`, `snapshot list`) but never the
|
`info`, `remote info`, `snapshot verify`, `snapshot list`, `snapshot files`)
|
||||||
machine-consumed `config get` value or the `--json` documents, which a script
|
but never the machine-consumed `config get` value or the `--json` documents,
|
||||||
depends on. The confirmation prompts of `database delete`, `prune`,
|
which a script depends on. The confirmation prompts of `database delete`,
|
||||||
`snapshot purge` and `snapshot remove`, each with what it asks to delete, are
|
`prune`, `snapshot purge` and `snapshot remove`, each with what it asks to
|
||||||
also written this way and always shown: they are an interactive exchange the
|
delete, are also written this way and always shown: they are an interactive
|
||||||
operator must see.
|
exchange the operator must see.
|
||||||
|
|
||||||
`internal/ui` writes to stdout; it is the output the user asked for. The
|
`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,
|
exceptions are the startup banner and the error a failed command ends with,
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// filesMaxArgs is the maximum positional argument count of
|
||||||
|
// `snapshot files <snapshot-id> [path]`.
|
||||||
|
const filesMaxArgs = 2
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errSnapshotIDRequired = errors.New("snapshot ID required")
|
errSnapshotIDRequired = errors.New("snapshot ID required")
|
||||||
errWrongArgCount = errors.New("wrong argument count")
|
errWrongArgCount = errors.New("wrong argument count")
|
||||||
@@ -52,6 +56,7 @@ func NewSnapshotCommand() *cobra.Command {
|
|||||||
// Add subcommands
|
// Add subcommands
|
||||||
cmd.AddCommand(newSnapshotCreateCommand())
|
cmd.AddCommand(newSnapshotCreateCommand())
|
||||||
cmd.AddCommand(newSnapshotListCommand())
|
cmd.AddCommand(newSnapshotListCommand())
|
||||||
|
cmd.AddCommand(newSnapshotFilesCommand())
|
||||||
cmd.AddCommand(newSnapshotPurgeCommand())
|
cmd.AddCommand(newSnapshotPurgeCommand())
|
||||||
cmd.AddCommand(newSnapshotVerifyCommand())
|
cmd.AddCommand(newSnapshotVerifyCommand())
|
||||||
cmd.AddCommand(newSnapshotRemoveCommand())
|
cmd.AddCommand(newSnapshotRemoveCommand())
|
||||||
@@ -172,6 +177,44 @@ func newSnapshotListCommand() *cobra.Command {
|
|||||||
return cmd
|
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
|
// newSnapshotPurgeCommand creates the 'snapshot purge' subcommand
|
||||||
func newSnapshotPurgeCommand() *cobra.Command {
|
func newSnapshotPurgeCommand() *cobra.Command {
|
||||||
opts := &vaultik.SnapshotPurgeOptions{}
|
opts := &vaultik.SnapshotPurgeOptions{}
|
||||||
|
|||||||
@@ -396,6 +396,8 @@ func Parse(data []byte) (*Config, error) {
|
|||||||
// never the value. An empty list is accepted, because only snapshot
|
// never the value. An empty list is accepted, because only snapshot
|
||||||
// create needs a recipient and it checks for one itself
|
// create needs a recipient and it checks for one itself
|
||||||
// - At least one snapshot must be configured with at least one path
|
// - 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)
|
// - Storage must be configured (either storage_url or s3.* fields)
|
||||||
// - The index path must be absolute: a relative one would resolve against
|
// - The index path must be absolute: a relative one would resolve against
|
||||||
// the working directory, so each directory would get its own index
|
// 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 {
|
err := c.validateSnapshots()
|
||||||
return errNoSnapshots
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, snap := range c.Snapshots {
|
err = c.validateExcludes()
|
||||||
if len(snap.Paths) == 0 {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %q", errSnapshotNoPaths, name)
|
return err
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate storage configuration
|
// Validate storage configuration
|
||||||
err := c.validateStorage()
|
err = c.validateStorage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -466,6 +468,22 @@ func (c *Config) Validate() error {
|
|||||||
return nil
|
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
|
// validateAgeRecipient parses one age_recipients entry with the age library
|
||||||
// and returns a value-free error on failure. A recipient string can be
|
// 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
|
// sensitive (an operator may paste a secret key by mistake), so neither the
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"sneak.berlin/go/vaultik/internal/blob"
|
"sneak.berlin/go/vaultik/internal/blob"
|
||||||
"sneak.berlin/go/vaultik/internal/chunker"
|
"sneak.berlin/go/vaultik/internal/chunker"
|
||||||
"sneak.berlin/go/vaultik/internal/database"
|
"sneak.berlin/go/vaultik/internal/database"
|
||||||
|
"sneak.berlin/go/vaultik/internal/exclude"
|
||||||
"sneak.berlin/go/vaultik/internal/log"
|
"sneak.berlin/go/vaultik/internal/log"
|
||||||
"sneak.berlin/go/vaultik/internal/storage"
|
"sneak.berlin/go/vaultik/internal/storage"
|
||||||
"sneak.berlin/go/vaultik/internal/types"
|
"sneak.berlin/go/vaultik/internal/types"
|
||||||
@@ -221,8 +222,12 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
|||||||
progress = NewProgressReporter()
|
progress = NewProgressReporter()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile exclude patterns
|
compiledExclude, err := compileExcludePatterns(cfg.Exclude)
|
||||||
compiledExclude := compileExcludePatterns(cfg.Exclude)
|
if err != nil {
|
||||||
|
log.Error("Failed to compile exclude patterns", "error", err)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
uiw := cfg.UI
|
uiw := cfg.UI
|
||||||
if uiw == nil {
|
if uiw == nil {
|
||||||
@@ -2318,51 +2323,18 @@ func wrapPermissionError(path string, err error) error {
|
|||||||
"with --skip-errors to continue past unreadable files)", path, err)
|
"with --skip-errors to continue past unreadable files)", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// compileExcludePatterns compiles the exclude patterns into glob matchers
|
// compileExcludePatterns compiles the exclude patterns into glob matchers.
|
||||||
func compileExcludePatterns(patterns []string) []compiledPattern {
|
// Config.Validate has already refused a pattern that does not compile.
|
||||||
|
func compileExcludePatterns(patterns []string) ([]compiledPattern, error) {
|
||||||
var compiled []compiledPattern
|
var compiled []compiledPattern
|
||||||
|
|
||||||
for _, p := range patterns {
|
for _, p := range patterns {
|
||||||
if p == "" {
|
globs, anchored, err := exclude.Compile(p)
|
||||||
continue
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if pattern is anchored (starts with /)
|
for _, g := range globs {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
compiled = append(compiled, compiledPattern{
|
compiled = append(compiled, compiledPattern{
|
||||||
pattern: g,
|
pattern: g,
|
||||||
anchored: anchored,
|
anchored: anchored,
|
||||||
@@ -2371,26 +2343,7 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return compiled
|
return compiled, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldExclude checks if a path should be excluded based on exclude patterns
|
// shouldExclude checks if a path should be excluded based on exclude patterns
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ var (
|
|||||||
errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot")
|
errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot")
|
||||||
errFilesFailedVerify = errors.New("files failed verification")
|
errFilesFailedVerify = errors.New("files failed verification")
|
||||||
errDecryptionKeyRequired = errors.New(
|
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 " +
|
"Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
|
||||||
"age private key file:\n" +
|
"age private key file:\n" +
|
||||||
" export VAULTIK_AGE_SECRET_KEY=\"$(cat vaultik_backup_private_key.txt)\"")
|
" 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)
|
return fmt.Errorf("downloading snapshot database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer v.closeSnapshotDB(tempDB, tempDir)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
repos := database.NewRepositories(tempDB)
|
repos := database.NewRepositories(tempDB)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in new issue
Block a user