1 Commits
Author SHA1 Message Date
sneak f2138dafc8 Name the S3 keys when storage_url moves to s3:// first (closes #428)
check / check (push) Successful in 10m27s
On a config that loads, `config set storage_url s3://...` is refused
until `s3.access_key_id` and `s3.secret_access_key` are set. The refusal
now says to set those two keys first, then `storage_url`.

Model: opus-5-5
2026-11-11 05:21:31 +00:00
32 changed files with 196 additions and 1307 deletions

No files matched your search

+22 -67
View File
@@ -2,9 +2,9 @@
`vaultik` is an incremental backup tool written in Go. It encrypts data using an
`age` public key and uploads each encrypted blob directly to a remote
S3-compatible object store. The backed-up system never needs the private key. A
backup needs only the public key and the credentials to write to the object
store, such as S3 API keys.
S3-compatible object store. It requires no private keys, secrets, or credentials
(other than those required to PUT to encrypted object storage, such as S3 API
keys) stored on the backed-up system.
## quickstart
@@ -171,7 +171,6 @@ 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
@@ -224,24 +223,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.
These read-only commands do not take the lock, so they are never blocked, even
while a backup is in progress:
- `info`
- `snapshot list`
- `snapshot files`
- `snapshot verify`
- `remote info`
`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.
These commands do not take the lock either:
- `config`
- `database delete`
- `completion`
- `version`
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.
### stdout and stderr
@@ -274,9 +261,9 @@ work as written.
### environment variables
- `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for
`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.
`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.
`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`)
@@ -421,26 +408,6 @@ 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`.
- In the table, a path that holds a newline or another non-printable character
is shown in double quotes, with that character escaped, as in `"a\nb"`.
- The mode is in Go's file-mode notation, in the table and in `--json`. Letters
before the nine permission characters show the file type and any special bit,
as in `dtrwxrwxrwx` for a sticky directory. These letters differ from `ls -l`:
- `L`: a symbolic link, which `ls -l` shows as `l`.
- `u`: setuid, which `ls -l` shows as `s` in place of the owner's `x`.
- `g`: setgid, which `ls -l` shows as `s` in place of the group's `x`.
- `t`: sticky, which `ls -l` shows as `t` in place of the others' `x`.
**`snapshot verify`**: Verify snapshot integrity.
- Default (shallow): checks that every blob the manifest lists is present in
@@ -755,7 +722,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 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. |
| `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. |
| `snapshots` | (required) | Named snapshot definitions with paths and excludes |
| `storage_url` | | Storage backend URL (`s3://`, `file://`, `rclone://`) |
| `s3.*` | | Legacy S3 configuration (endpoint, bucket, credentials) |
@@ -872,27 +839,15 @@ environment variable is unset (https://no-color.org/).
`database delete`.
- **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 report or table of each of these commands:
- `version`
- `info`
- `remote info`
- `snapshot verify`
- `snapshot list`
- `snapshot files`
`config get` values and every `--json` document are written plain too.
`--quiet` silences the reports and tables above, but never a `config get`
value or a `--json` document, which a script depends on.
These commands write their confirmation prompt plain too, with what it asks
to delete:
- `database delete`
- `prune`
- `snapshot purge`
- `snapshot remove`
`--quiet` never silences these prompts: each is an interactive exchange the
operator must see.
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.
`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,
+9 -17
View File
@@ -265,23 +265,15 @@ so an operator can weigh them rather than discover them.
blob holds hundreds of chunks, so individual chunk lengths are not visible in
the blob's size; lowering the limit toward the chunk size begins to expose
them.
4. **Decrypted data on local disk.** These commands stage plaintext under
`$TMPDIR`:
- `snapshot restore`: decrypted blobs under `vaultik-blobcache-*/`, with no
size cap, and the decrypted metadata database at
`vaultik-restore-*/snapshot.db`.
- `snapshot files`: the decrypted metadata database at
`vaultik-restore-*/snapshot.db`.
- `verify --deep`: the decrypted metadata database at
`vaultik-verify-*/snapshot.db`.
- `snapshot create`: a plaintext copy of the index at
`vaultik-snapshot-*/snapshot.db`.
These files are created `0600` and removed on success, but a `kill -9` or a
power loss leaves them behind — delete any leftover `vaultik-*` directory
under `$TMPDIR` by hand. `$TMPDIR` should be trusted to the same degree as
the restore target.
4. **Decrypted data on local disk.** Several commands stage plaintext under
`$TMPDIR`: `snapshot restore` writes decrypted blobs under
`vaultik-blobcache-*/` (no size cap) and the decrypted metadata database at
`vaultik-restore-*/snapshot.db`; `verify --deep` writes that database at
`vaultik-verify-*/snapshot.db`; `snapshot create` keeps a plaintext copy of
the index at `vaultik-snapshot-*/snapshot.db`. These files are created `0600`
and removed on success, but a `kill -9` or a power loss leaves them behind —
delete any leftover `vaultik-*` directory under `$TMPDIR` by hand. `$TMPDIR`
should be trusted to the same degree as the restore target.
5. **Store permissions differ per command.** The backed-up host needs only PUT
to run `snapshot create`: it writes blobs and metadata and neither reads nor
deletes them. Other commands need more — `snapshot verify`,
+6 -7
View File
@@ -38,11 +38,10 @@ 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 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 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
)
@@ -311,7 +310,7 @@ func RunOperation(
}
// runVaultikApp runs the standard single-operation command lifecycle
// shared by the snapshot list/files/purge/remove and remote nuke subcommands:
// shared by the snapshot list/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
@@ -338,7 +337,7 @@ func runVaultikApp(
},
Mode: mode,
}, op, func(err error) {
log.Info(failMsg, "error", err)
log.Error(failMsg, "error", err)
ReportErrorf("%s: %v", failMsg, err)
})
}
+1 -1
View File
@@ -268,7 +268,7 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
{configWithoutRecipients, "snapshots.home.paths", "/srv",
"into []string"},
{configWithoutRecipients, "chunk_size", "fast",
`chunk_size: "fast" is not a size`},
"invalid size format"},
// Values of the right type that fail validation.
{configWithoutRecipients, "compression_level", "99",
"compression_level must be between 1 and 19"},
-4
View File
@@ -85,10 +85,6 @@ func emitStartupBanner(args []string, w io.Writer) {
// 🛑 ERROR: format. Use it from goroutine error paths (where returning
// an error to cobra isn't an option) and anywhere else a CLI command
// must surface a failure outside the normal RunE return path.
//
// This line is the one report of the failure. A caller that also logs
// the failure logs it at info level, so the record appears only under
// --verbose or --debug and the error is not printed twice.
func ReportErrorf(format string, args ...any) {
ui.New(os.Stderr).Errorf(format, args...)
}
-205
View File
@@ -1,205 +0,0 @@
package cli //nolint:testpackage // shares the fixtures and capture helpers
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/ui"
)
const (
// errorLinePrefix starts the final line that reports a failed command.
errorLinePrefix = "ERROR: "
flagVerbose = "--verbose"
cmdRestore = "restore"
// blobsDir is the directory of a destination store that holds the
// blobs.
blobsDir = "blobs"
)
// TestEntryFailureIsPrintedOnce runs commands that fail after startup
// against a destination they cannot use. `info` is not among them: it
// cannot fail after startup.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntryFailureIsPrintedOnce(t *testing.T) {
for _, command := range [][]string{
{cmdSnapshot, cmdCreate},
{cmdSnapshot, cmdRemove, someSnapshotID},
{cmdSnapshot, cmdVerify, someSnapshotID},
{cmdRemote, cmdInfo},
{cmdPrune, flagJSON},
} {
for _, verbose := range []bool{false, true} {
name := fmt.Sprintf("%s verbose=%t", strings.Join(command, " "), verbose)
t.Run(name, func(t *testing.T) {
requireErrorPrintedOnce(t, verbose, append([]string{flagConfig,
writeUnusableDestinationConfig(t)}, command...)...)
})
}
}
}
// TestEntryBlobUploadFailureIsPrintedOnce backs up to a destination
// whose blobs directory is a file, so the blob upload fails.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntryBlobUploadFailureIsPrintedOnce(t *testing.T) {
for _, verbose := range []bool{false, true} {
t.Run(fmt.Sprintf("verbose=%t", verbose), func(t *testing.T) {
configPath, storeDir := writeBackupConfig(t)
require.NoError(t, os.WriteFile(
filepath.Join(storeDir, blobsDir), nil, 0o600))
requireErrorPrintedOnce(t, verbose,
flagConfig, configPath, cmdSnapshot, cmdCreate)
})
}
}
// TestEntryBlobDownloadFailureIsPrintedOnce backs up, deletes the blobs
// from the destination, and restores, so the blob download fails.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntryBlobDownloadFailureIsPrintedOnce(t *testing.T) {
for _, verbose := range []bool{false, true} {
t.Run(fmt.Sprintf("verbose=%t", verbose), func(t *testing.T) {
configPath, storeDir := writeBackupConfigWithPrivateKey(t)
code, _, _ := runEntry(t,
flagConfig, configPath, cmdSnapshot, cmdCreate)
require.Equal(t, 0, code)
require.NoError(t, os.RemoveAll(filepath.Join(storeDir, blobsDir)))
requireErrorPrintedOnce(t, verbose, flagConfig, configPath,
cmdSnapshot, cmdRestore, onlySnapshotID(t, configPath), t.TempDir())
})
}
}
// requireErrorPrintedOnce runs Entry with args, adding --verbose when
// verbose is set. The command must fail, and stderr must carry its error
// once, as the final ERROR: line. A log record carrying the same error
// appears only under --verbose.
func requireErrorPrintedOnce(t *testing.T, verbose bool, args ...string) {
t.Helper()
if verbose {
args = append(args, flagVerbose)
}
code, _, stderr := runEntry(t, args...)
require.Equal(t, 1, code)
errorLine, records := failureOutput(t, stderr)
if !verbose {
assert.Zero(t, records,
"without --verbose no log record repeats the error:\n%s", stderr)
return
}
assert.NotZero(t, records,
"--verbose must keep the log record of %q", errorLine)
}
// failureOutput requires stderr to hold exactly one ERROR: line and
// returns it, with the number of log records whose error attribute is
// the error that line reports. stderr is a pipe here, so every log
// record is a JSON object on its own line.
func failureOutput(t *testing.T, stderr string) (string, int) {
t.Helper()
var (
errorLines []string
records []map[string]any
)
for line := range strings.Lines(stderr) {
var record map[string]any
if json.Unmarshal([]byte(line), &record) == nil {
records = append(records, record)
continue
}
if strings.Contains(line, errorLinePrefix) {
errorLines = append(errorLines, strings.TrimSpace(line))
}
}
require.Len(t, errorLines, 1,
"the error must be printed once as an ERROR: line:\n%s", stderr)
// An error can run over several lines; the ERROR: line holds the
// first of them.
repeats := 0
for _, record := range records {
failure, ok := record["error"].(string)
firstLine, _, _ := strings.Cut(failure, "\n")
if ok && strings.Contains(errorLines[0], firstLine) {
repeats++
}
}
return errorLines[0], repeats
}
// writeBackupConfigWithPrivateKey builds what writeBackupConfig builds,
// with a generated key pair in place of its recipient. The config also
// holds the private key, so that restore can decrypt the backup. Returns
// the config path and the store directory.
func writeBackupConfigWithPrivateKey(t *testing.T) (string, string) {
t.Helper()
configPath, storeDir := writeBackupConfig(t)
identity, err := age.GenerateX25519Identity()
require.NoError(t, err)
out := ui.NewWithColor(io.Discard, false)
require.NoError(t, writeConfigSet(out, configPath,
"age_recipients.0", identity.Recipient().String()))
require.NoError(t, writeConfigSet(out, configPath,
"age_secret_key", identity.String()))
return configPath, storeDir
}
// onlySnapshotID returns the ID of the one snapshot in the local index
// that writeBackupConfig puts beside the config file.
func onlySnapshotID(t *testing.T, configPath string) string {
t.Helper()
ctx := context.Background()
db, err := database.New(ctx,
filepath.Join(filepath.Dir(configPath), "index.sqlite"))
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
backedUp, err := database.NewRepositories(db).Snapshots.GetOnlySnapshot(ctx)
require.NoError(t, err)
return backedUp.ID.String()
}
@@ -61,8 +61,8 @@ func TestEntrySnapshotCreateRejectsKeepNewerThanBeforeBackup(t *testing.T) {
// TestEntrySnapshotCreateReportsPostBackupPruneFailure makes the
// post-backup prune fail after a good backup, by planting a manifest
// that cannot be decoded, and checks that the failure is reported once,
// as the prune's and not as a failed snapshot creation.
// that cannot be decoded, and checks that the failure is reported as the
// prune's and not as a failed snapshot creation.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
@@ -83,9 +83,6 @@ func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
assert.Contains(t, stderr, "Backup completed, but post-backup prune failed")
assert.NotContains(t, stderr, "Snapshot creation failed")
_, repeats := failureOutput(t, stderr)
assert.Zero(t, repeats, "no log record may repeat the error:\n%s", stderr)
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
require.NoError(t, err)
assert.Len(t, snapshots, 2,
@@ -96,8 +93,7 @@ func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
// empties the source directory, as a volume that is not mounted leaves
// its mount point, and runs `snapshot create --prune`. The command exits
// non-zero and reports the backup as completed, not as a failed snapshot
// creation. The error is printed once. See
// https://git.eeqj.de/sneak/vaultik/issues/388.
// creation. See https://git.eeqj.de/sneak/vaultik/issues/388.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
@@ -118,9 +114,6 @@ func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
"Backup completed, but 1 source path(s) were empty")
assert.NotContains(t, stderr, "Snapshot creation failed")
_, repeats := failureOutput(t, stderr)
assert.Zero(t, repeats, "no log record may repeat the error:\n%s", stderr)
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
require.NoError(t, err)
assert.Len(t, snapshots, 2, "the older snapshot is kept")
@@ -131,8 +124,7 @@ func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
// directory as a mount point, and runs `snapshot create --prune`, which
// does not find it as one. The command exits non-zero, reports the
// backup as completed, not as a failed snapshot creation, and keeps the
// older snapshot. The error is printed once. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
// older snapshot. See https://git.eeqj.de/sneak/vaultik/issues/395.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing(
@@ -167,9 +159,6 @@ func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing(
"an earlier snapshot recorded were not found")
assert.NotContains(t, stderr, "Snapshot creation failed")
_, repeats := failureOutput(t, stderr)
assert.Zero(t, repeats, "no log record may repeat the error:\n%s", stderr)
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
require.NoError(t, err)
assert.Len(t, snapshots, 2, "the older snapshot is kept")
-47
View File
@@ -1,47 +0,0 @@
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", "files", "purge", "verify", "remove", "restore",
"create", "list", "purge", "verify", "remove", "restore",
}
for _, expected := range expectedSubCommands {
found := false
+1 -1
View File
@@ -39,7 +39,7 @@ func NewInfoCommand() *cobra.Command {
}, func(v *vaultik.Vaultik) error {
return v.ShowInfo()
}, func(err error) {
log.Info("Failed to show info", "error", err)
log.Error("Failed to show info", "error", err)
ReportErrorf("Failed to show info: %v", err)
})
},
+1 -1
View File
@@ -60,7 +60,7 @@ referenced.`,
}, func(v *vaultik.Vaultik) error {
return v.Prune(opts)
}, func(err error) {
log.Info("Prune operation failed", "error", err)
log.Error("Prune operation failed", "error", err)
ReportErrorf("Prune failed: %v", err)
})
},
+1 -1
View File
@@ -98,7 +98,7 @@ func newRemoteInfoCommand() *cobra.Command {
}, func(v *vaultik.Vaultik) error {
return v.RemoteInfo(jsonOutput)
}, func(err error) {
log.Info("Failed to get remote info", "error", err)
log.Error("Failed to get remote info", "error", err)
ReportErrorf("Failed to get remote info: %v", err)
})
},
+4 -47
View File
@@ -9,10 +9,6 @@ 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")
@@ -56,7 +52,6 @@ func NewSnapshotCommand() *cobra.Command {
// Add subcommands
cmd.AddCommand(newSnapshotCreateCommand())
cmd.AddCommand(newSnapshotListCommand())
cmd.AddCommand(newSnapshotFilesCommand())
cmd.AddCommand(newSnapshotPurgeCommand())
cmd.AddCommand(newSnapshotVerifyCommand())
cmd.AddCommand(newSnapshotRemoveCommand())
@@ -114,7 +109,7 @@ on Linux), then /etc/vaultik/config.yml.`,
return v.CreateSnapshot(opts)
}, func(err error) {
if errors.Is(err, vaultik.ErrPostBackupPruneFailed) {
log.Info("Post-backup prune failed", "error", err)
log.Error("Post-backup prune failed", "error", err)
ReportErrorf("Backup completed, but %v", err)
return
@@ -123,13 +118,13 @@ on Linux), then /etc/vaultik/config.yml.`,
if errors.Is(err, vaultik.ErrFilesUnreadable) ||
errors.Is(err, vaultik.ErrSourcePathEmpty) ||
errors.Is(err, vaultik.ErrVolumeNotMounted) {
log.Info("Backup completed with an error", "error", err)
log.Error("Backup completed with an error", "error", err)
ReportErrorf("Backup completed, but %v", err)
return
}
log.Info("Snapshot creation failed", "error", err)
log.Error("Snapshot creation failed", "error", err)
ReportErrorf("Snapshot creation failed: %v", err)
})
},
@@ -177,44 +172,6 @@ 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{}
@@ -301,7 +258,7 @@ func newSnapshotVerifyCommand() *cobra.Command {
return
}
log.Info("Verification failed", "error", err)
log.Error("Verification failed", "error", err)
ReportErrorf("Verification failed: %v", err)
})
},
+1 -1
View File
@@ -104,7 +104,7 @@ func runRestore(cmd *cobra.Command, args []string, opts *RestoreOptions) error {
SkipErrors: rootFlags.SkipErrors,
})
}, func(err error) {
log.Info("Restore operation failed", "error", err)
log.Error("Restore operation failed", "error", err)
ReportErrorf("Restore failed: %v", err)
})
}
+10 -30
View File
@@ -295,8 +295,7 @@ func Load(path string) (*Config, error) {
}
// Parse reads YAML config data as Load does, without validating it. It
// refuses a key that no setting has and a size setting whose value is not a
// size, naming the setting. It applies default values for optional
// refuses a key that no setting has, applies default values for optional
// fields, fills in ${...} references using smartconfig, and expands ~ in
// paths.
func Parse(data []byte) (*Config, error) {
@@ -321,9 +320,8 @@ func Parse(data []byte) (*Config, error) {
return nil, fmt.Errorf("failed to load config file: %w", err)
}
// A ${...} reference can bring in keys that are not in the file, and
// is the value of a size setting only once it is filled in.
err = checkFilledConfig(sc.Data())
// A ${...} reference can bring in keys that are not in the file.
err = checkFilledKeys(sc.Data())
if err != nil {
return nil, err
}
@@ -396,8 +394,6 @@ 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
@@ -418,18 +414,18 @@ func (c *Config) Validate() error {
}
}
err := c.validateSnapshots()
if err != nil {
return err
if len(c.Snapshots) == 0 {
return errNoSnapshots
}
err = c.validateExcludes()
if err != nil {
return err
for name, snap := range c.Snapshots {
if len(snap.Paths) == 0 {
return fmt.Errorf("%w: %q", errSnapshotNoPaths, name)
}
}
// Validate storage configuration
err = c.validateStorage()
err := c.validateStorage()
if err != nil {
return err
}
@@ -468,22 +464,6 @@ 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
@@ -1,39 +0,0 @@
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
@@ -1,65 +0,0 @@
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)
}
})
}
}
+5 -11
View File
@@ -129,11 +129,10 @@ func checkKeys(node *yaml.Node, typ reflect.Type, path string,
return nil
}
// checkFilledConfig checks data, the config after smartconfig has filled in
// its ${...} references: its keys with checkKeys, then its size settings
// with checkSizes. A key that a reference brings in is not in the file, and
// the copy that Encode makes has no line numbers.
func checkFilledConfig(data map[string]any) error {
// checkFilledKeys is checkKeys for data, the config after smartconfig has
// filled in its ${...} references. A key that a reference brings in is not
// in the file, and the copy that Encode makes has no line numbers.
func checkFilledKeys(data map[string]any) error {
var filled yaml.Node
err := filled.Encode(data)
@@ -141,13 +140,8 @@ func checkFilledConfig(data map[string]any) error {
return fmt.Errorf("failed to marshal config data: %w", err)
}
err = checkKeys(&filled, reflect.TypeFor[Config](), "",
return checkKeys(&filled, reflect.TypeFor[Config](), "",
map[*yaml.Node]bool{}, map[nodeAndType]bool{})
if err != nil {
return err
}
return checkSizes(&filled, nil)
}
// checkMappingKeys is checkKeys for a map.
+11 -77
View File
@@ -4,20 +4,13 @@ import (
"errors"
"fmt"
"math"
"reflect"
"slices"
"strconv"
"strings"
"github.com/dustin/go-humanize"
"gopkg.in/yaml.v3"
)
var (
errSizeType = errors.New("size must be a number or string")
errSizeTooLarge = errors.New("size exceeds maximum supported value")
errNotASize = errors.New(
"is not a size; use a number of bytes or a unit such as 10MB")
)
// Size represents a byte size that can be specified in configuration files.
@@ -39,15 +32,6 @@ func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
return nil
}
// A number too large for an int64 decodes as a float64. Its text can be
// 1e+20, which is not a size string.
var floatVal float64
err = unmarshal(&floatVal)
if err == nil && floatVal > math.MaxInt64 {
return errSizeTooLarge
}
// Try to unmarshal as string
var strVal string
@@ -56,66 +40,17 @@ func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
return errSizeType
}
size, err := ParseSize(strVal)
// Parse the string using go-humanize
bytes, err := humanize.ParseBytes(strVal)
if err != nil {
return err
return fmt.Errorf("invalid size format: %w", err)
}
*s = size
return nil
}
// checkSizes returns an error naming the first size setting in node whose
// value is not a size. node is the config, or the part of it at the dotted
// keys, after smartconfig has filled in its ${...} references.
func checkSizes(node *yaml.Node, keys []string) error {
typ, err := SettingType(keys)
if err != nil {
// Unknown keys are refused before this check, so keys is in one of
// two places. The env section holds no settings. A value of the
// wrong shape, such as a map given for hostname or inside exclude,
// is reported later by the YAML decoder.
return nil //nolint:nilerr // not an error here
if bytes > math.MaxInt64 {
return fmt.Errorf("%w: %s", errSizeTooLarge, strVal)
}
if typ == reflect.TypeFor[Size]() {
var size Size
err = node.Decode(&size)
if err != nil {
return fmt.Errorf("%s: %w", strings.Join(keys, "."), err)
}
return nil
}
switch node.Kind {
case yaml.DocumentNode:
for _, child := range node.Content {
err = checkSizes(child, keys)
if err != nil {
return err
}
}
case yaml.MappingNode:
for i := 0; i+1 < len(node.Content); i += 2 {
err = checkSizes(node.Content[i+1],
slices.Concat(keys, []string{node.Content[i].Value}))
if err != nil {
return err
}
}
case yaml.SequenceNode:
for i, child := range node.Content {
err = checkSizes(child, slices.Concat(keys, []string{strconv.Itoa(i)}))
if err != nil {
return err
}
}
case yaml.ScalarNode, yaml.AliasNode:
// A scalar holds no settings, and the filled config has no aliases.
}
*s = Size(bytes)
return nil
}
@@ -135,17 +70,16 @@ func (s Size) String() string {
return humanize.Bytes(uint64(s))
}
// ParseSize parses a size string, such as "10MB", into a Size value. An
// error quotes s.
// ParseSize parses a size string into a Size value
func ParseSize(s string) (Size, error) {
bytes, err := humanize.ParseBigBytes(s)
bytes, err := humanize.ParseBytes(s)
if err != nil {
return 0, fmt.Errorf("%q %w", s, errNotASize)
return 0, fmt.Errorf("invalid size format: %w", err)
}
if !bytes.IsInt64() {
if bytes > math.MaxInt64 {
return 0, fmt.Errorf("%w: %s", errSizeTooLarge, s)
}
return Size(bytes.Int64()), nil
return Size(bytes), nil
}
-120
View File
@@ -1,120 +0,0 @@
package config //nolint:testpackage // checks errNotASize and errSizeTooLarge
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// TestParseRefusesValueThatIsNotASize checks that a size setting whose
// value is not a size fails the parse with an error naming the setting and
// quoting the value, also when a ${...} reference fills the value in.
func TestParseRefusesValueThatIsNotASize(t *testing.T) {
t.Parallel()
sizePath := filepath.Join(t.TempDir(), "size")
err := os.WriteFile(sizePath, []byte("lots"), 0o600)
if err != nil {
t.Fatalf("write size: %v", err)
}
tests := []struct {
name string
yaml string
want string
}{
{"chunk_size", keysTestConfig + "chunk_size: banana\n",
`chunk_size: "banana" is not a size; ` +
"use a number of bytes or a unit such as 10MB"},
{"blob_size_limit", keysTestConfig + "blob_size_limit: 10XB\n",
`blob_size_limit: "10XB" is not a size; ` +
"use a number of bytes or a unit such as 10MB"},
{"s3.part_size", strings.Replace(keysTestConfig, " region:",
" part_size: big\n region:", 1),
`s3.part_size: "big" is not a size; ` +
"use a number of bytes or a unit such as 10MB"},
{"from a reference",
keysTestConfig + "chunk_size: ${FILE:" + sizePath + "}\n",
`chunk_size: "lots" is not a size; ` +
"use a number of bytes or a unit such as 10MB"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := Parse([]byte(tt.yaml))
if !errors.Is(err, errNotASize) {
t.Fatalf("Parse() error = %v, want errNotASize", err)
}
if err.Error() != tt.want {
t.Errorf("Parse() error = %q, want %q", err.Error(), tt.want)
}
})
}
}
// TestParseRefusesSizeTooLarge checks that a size setting whose value is
// too large for a size fails the parse with an error naming the setting.
// YAML reads the second value as a float.
func TestParseRefusesSizeTooLarge(t *testing.T) {
t.Parallel()
tests := []struct {
value string
want string
}{
{"100EB", "chunk_size: size exceeds maximum supported value: 100EB"},
{"99999999999999999999",
"chunk_size: size exceeds maximum supported value"},
}
for _, tt := range tests {
t.Run(tt.value, func(t *testing.T) {
t.Parallel()
_, err := Parse([]byte(keysTestConfig + "chunk_size: " + tt.value + "\n"))
if !errors.Is(err, errSizeTooLarge) {
t.Fatalf("Parse() error = %v, want errSizeTooLarge", err)
}
if err.Error() != tt.want {
t.Errorf("Parse() error = %q, want %q", err.Error(), tt.want)
}
})
}
}
// TestParseAcceptsSizes checks that size settings given as a number of
// bytes, as a unit, and through a ${...} reference still parse.
func TestParseAcceptsSizes(t *testing.T) {
t.Parallel()
sizePath := filepath.Join(t.TempDir(), "size")
err := os.WriteFile(sizePath, []byte("20MB"), 0o600)
if err != nil {
t.Fatalf("write size: %v", err)
}
data := strings.Replace(keysTestConfig, " region:",
" part_size: 6000000\n region:", 1) +
"chunk_size: ${FILE:" + sizePath + "}\n" +
"blob_size_limit: 1GB\n"
cfg, err := Parse([]byte(data))
if err != nil {
t.Fatalf("Parse() unexpected error: %v", err)
}
if cfg.ChunkSize != 20_000_000 || cfg.BlobSizeLimit != 1_000_000_000 ||
cfg.S3.PartSize != 6_000_000 {
t.Errorf("chunk_size, blob_size_limit, s3.part_size = %d, %d, %d, "+
"want 20000000, 1000000000, 6000000",
cfg.ChunkSize, cfg.BlobSizeLimit, cfg.S3.PartSize)
}
}
+10 -11
View File
@@ -219,8 +219,7 @@ func retryOpen(ctx context.Context, path string) (*DB, error) {
// errUntrustedSnapshotSchema is returned when a downloaded snapshot
// database carries schema objects the real schema never defines, or is
// missing a table the restore, deep-verify and `snapshot files` queries
// read.
// missing a table the restore and deep-verify queries read.
var errUntrustedSnapshotSchema = errors.New(
"downloaded snapshot database has an untrusted schema")
@@ -242,12 +241,12 @@ func snapshotReadOnlyDSN(path string) string {
// OpenReadOnly opens an already-materialized SQLite file for read-only
// querying of a snapshot database downloaded from the store, used by
// restore, deep verify and `snapshot files`. Unlike New it never applies
// schema migrations and never writes: the connection is opened read-only
// with query_only and trusted_schema=OFF. It refuses any file whose schema
// carries a trigger, view or virtual table, or lacks an expected table, so
// a forged file cannot redefine what the restore queries return. The
// caller owns the file and must remove it.
// restore and deep verify. Unlike New it never applies schema migrations
// and never writes: the connection is opened read-only with query_only
// and trusted_schema=OFF. It refuses any file whose schema carries a
// trigger, view or virtual table, or lacks an expected table, so a forged
// file cannot redefine what the restore queries return. The caller owns
// the file and must remove it.
func OpenReadOnly(ctx context.Context, path string) (*DB, error) {
conn, err := sql.Open("sqlite", snapshotReadOnlyDSN(path))
if err != nil {
@@ -277,9 +276,9 @@ func OpenReadOnly(ctx context.Context, path string) (*DB, error) {
// the plain table set the real schema defines. Any trigger, view or
// virtual table, or a missing expected table, fails the open.
func verifySnapshotSchema(ctx context.Context, conn *sql.DB) error {
// expectedSnapshotTables are the tables the restore, deep-verify and
// `snapshot files` queries read. A downloaded database missing any of
// them is not a genuine snapshot database and is refused.
// expectedSnapshotTables are the tables the restore and deep-verify
// queries read. A downloaded database missing any of them is not a
// genuine snapshot database and is refused.
expectedSnapshotTables := []string{
"blob_chunks",
"blobs",
+4 -5
View File
@@ -238,11 +238,10 @@ func (r *SnapshotRepository) GetByID(
// GetOnlySnapshot returns the sole snapshot in an exported per-snapshot
// metadata database. The backup path writes each snapshot's database with
// exactly one snapshot row (see cleanSnapshotDB), so restore, deep verify
// and `snapshot files` expect exactly one. Zero rows return
// ErrNoSnapshotInDatabase and more than one returns
// ErrMultipleSnapshotsInDatabase; callers treat either as a failed
// identity check on the downloaded database.
// exactly one snapshot row (see cleanSnapshotDB), so restore and deep
// verify expect exactly one. Zero rows return ErrNoSnapshotInDatabase and
// more than one returns ErrMultipleSnapshotsInDatabase; callers treat
// either as a failed identity check on the downloaded database.
func (r *SnapshotRepository) GetOnlySnapshot(ctx context.Context) (*Snapshot, error) {
query := `
SELECT id, hostname, vaultik_version, vaultik_git_revision,
-77
View File
@@ -1,77 +0,0 @@
// 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
@@ -1,73 +0,0 @@
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)
}
}
})
}
}
+65 -20
View File
@@ -20,7 +20,6 @@ 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"
@@ -222,12 +221,8 @@ func NewScanner(cfg ScannerConfig) *Scanner {
progress = NewProgressReporter()
}
compiledExclude, err := compileExcludePatterns(cfg.Exclude)
if err != nil {
log.Error("Failed to compile exclude patterns", "error", err)
return nil
}
// Compile exclude patterns
compiledExclude := compileExcludePatterns(cfg.Exclude)
uiw := cfg.UI
if uiw == nil {
@@ -1880,12 +1875,10 @@ func (s *Scanner) uploadBlobIfNeeded(
err = s.storage.PutWithProgress(ctx, blobPath, blobWithReader.Reader,
finishedBlob.Compressed, progressCallback)
if err != nil {
// The record is at info level: the returned error ends the backup
// and is printed as its ERROR: line. Only an interrupt cancels
// ctx, and the user is told about it, so an upload it stopped is
// not logged as a storage failure.
// Only an interrupt cancels ctx, and the user is told about it,
// so an upload it stopped is not logged as a storage failure.
if ctx.Err() == nil {
log.Info("Failed to upload blob", "hash", finishedBlob.Hash, "error", err)
log.Error("Failed to upload blob", "hash", finishedBlob.Hash, "error", err)
}
return 0, false, fmt.Errorf("uploading blob to storage: %w", err)
@@ -2323,18 +2316,51 @@ 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.
// Config.Validate has already refused a pattern that does not compile.
func compileExcludePatterns(patterns []string) ([]compiledPattern, error) {
// compileExcludePatterns compiles the exclude patterns into glob matchers
func compileExcludePatterns(patterns []string) []compiledPattern {
var compiled []compiledPattern
for _, p := range patterns {
globs, anchored, err := exclude.Compile(p)
if err != nil {
return nil, err
if p == "" {
continue
}
for _, g := range globs {
// 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
}
compiled = append(compiled, compiledPattern{
pattern: g,
anchored: anchored,
@@ -2343,7 +2369,26 @@ func compileExcludePatterns(patterns []string) ([]compiledPattern, error) {
}
}
return compiled, nil
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
}
// shouldExclude checks if a path should be excluded based on exclude patterns
+6 -6
View File
@@ -37,9 +37,9 @@ func uploadTestBlob(ctx context.Context, store storage.Storer) error {
}
// captureLog runs fn and returns what the logger wrote to stderr
// meanwhile, at the --verbose level. The logger writes to the os.Stderr
// it finds when it is initialized, so os.Stderr is pointed at a file
// first. Not parallel-safe: os.Stderr and the logger are process-global.
// meanwhile. The logger writes to the os.Stderr it finds when it is
// initialized, so os.Stderr is pointed at a file first. Not
// parallel-safe: os.Stderr and the logger are process-global.
func captureLog(t *testing.T, fn func()) string {
t.Helper()
@@ -53,7 +53,7 @@ func captureLog(t *testing.T, fn func()) string {
previous := os.Stderr
os.Stderr = stderrFile
log.Initialize(log.Config{Verbose: true})
log.Initialize(log.Config{})
fn()
@@ -107,10 +107,10 @@ func TestInterruptedUploadIsNotLoggedAsError(t *testing.T) {
}
}
// An upload that fails for any other reason is still logged.
// An upload that fails for any other reason is still logged as an error.
//
//nolint:paralleltest // captureLog replaces os.Stderr
func TestFailedUploadIsLogged(t *testing.T) {
func TestFailedUploadIsLoggedAsError(t *testing.T) {
inner, err := storage.NewFileStorer(t.TempDir())
if err != nil {
t.Fatal(err)
+6 -10
View File
@@ -17,9 +17,6 @@
// Count, Percent) return ANSI-colored strings the caller composes into
// the message body. When color is disabled (non-TTY output or NO_COLOR
// set) all formatters return plain text.
//
// QuoteUnprintable is the same escaping without colour, for plain tables
// that print paths from the snapshot database.
package ui
import (
@@ -230,17 +227,17 @@ func (w *Writer) Hex(s string) string {
short = s[:hexAbbrevLen] + "..."
}
return w.paint(ansiCyan, QuoteUnprintable(short))
return w.paint(ansiCyan, sanitize(short))
}
// Snapshot colorizes a snapshot ID (full, no abbreviation).
func (w *Writer) Snapshot(id string) string {
return w.paint(ansiCyan+ansiBold, QuoteUnprintable(id))
return w.paint(ansiCyan+ansiBold, sanitize(id))
}
// Path colorizes a filesystem path.
func (w *Writer) Path(p string) string {
return w.paint(ansiBlue, QuoteUnprintable(p))
return w.paint(ansiBlue, sanitize(p))
}
// Size colorizes a byte count using humanize.Bytes.
@@ -315,15 +312,14 @@ func (w *Writer) paint(color, s string) string {
return color + s + ansiReset
}
// QuoteUnprintable returns s unchanged when every rune in it is printable,
// and a double-quoted, backslash-escaped form (\n, \x1b, …) otherwise. The
// sanitize returns s unchanged when every rune in it is printable, and a
// double-quoted, backslash-escaped form (\n, \x1b, …) otherwise. The
// string value formatters escape their argument through this before
// painting: identifiers, paths and symlink targets they render come from
// the snapshot database, which is not trusted, and escaping must happen
// before colour is applied — the painted result already contains the
// escape codes the raw text would otherwise be indistinguishable from.
// Plain tables call it directly.
func QuoteUnprintable(s string) string {
func sanitize(s string) string {
for _, r := range s {
if !unicode.IsPrint(r) {
return strconv.Quote(s)
+1 -2
View File
@@ -160,8 +160,7 @@ func (v *Vaultik) FetchBlob(
// Not every backend's reader stops on cancel by itself (the file
// backend's does not), and a read stalled on a slow or hung source would
// otherwise outlast the shutdown timeout, so the process would exit
// before restore, deep verify or `snapshot files` removed its decrypted
// temporary files.
// before restore or deep verify removed its decrypted temporary files.
type closeOnCancelReader struct {
io.ReadCloser
+26 -32
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\n\n" +
"decryption key required for restore\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,7 +153,18 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
return fmt.Errorf("downloading snapshot database: %w", err)
}
defer v.closeSnapshotDB(tempDB, tempDir)
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)
}
}()
repos := database.NewRepositories(tempDB)
@@ -314,10 +325,9 @@ func (v *Vaultik) reportUnmatchedPaths(unmatched []string) error {
// identity it contains. The value may be a single key line or a whole
// age-keygen file with several identities; all of them are returned so
// blobgen (via age.Decrypt) can read a blob encrypted to any of their
// recipients. This is the first step of restore, deep verify and
// `snapshot files`, so a missing or unparseable key fails before anything
// is downloaded. The error names the configuration source but never the
// key value.
// recipients. This is the first step of both restore and deep verify, so
// a missing or unparseable key fails before anything is downloaded. The
// error names the configuration source but never the key value.
func (v *Vaultik) restoreIdentities() ([]age.Identity, error) {
if v.Config.AgeSecretKey == "" {
return nil, errDecryptionKeyRequired
@@ -657,19 +667,19 @@ func (v *Vaultik) restoreProgressTick(
// handleRestoreFileError records a per-file restore failure: fatal unless
// --skip-errors is set, in which case the file is counted as failed and
// dropped from the plan. A fatal failure is not logged here: the returned
// error is printed as the command's ERROR: line.
// dropped from the plan.
func (v *Vaultik) handleRestoreFileError(
plan *restorePlan, opts *RestoreOptions, result *RestoreResult,
file *database.File, fileID types.FileID, err error,
) error {
log.Error("Failed to restore file", "path", file.Path, "error", err)
if !opts.SkipErrors {
return fmt.Errorf(
"restoring %s: %w (pass --skip-errors to continue past "+
"restore failures)", file.Path, err)
}
log.Error("Failed to restore file", "path", file.Path, "error", err)
v.UI.Errorf("Failed to restore %s: %v. Skipping (--skip-errors).",
v.UI.Path(file.Path.String()), err)
@@ -870,34 +880,18 @@ 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
// metadata/<remoteKey>/db.zst.age is the snapshot that was requested: an
// attacker who swaps in another valid db.zst.age (which needs no key
// material) would otherwise redirect restore, deep verify and
// `snapshot files` to a different snapshot's contents. The exported
// per-snapshot database holds exactly one snapshot row, and a snapshot's
// remote key is derived from that row's ID, so the database is the
// requested one exactly when its sole snapshot hashes back to remoteKey.
// Comparing the requested identifier directly would not do: it may be a
// remote-key prefix a recovery host uses in place of a human snapshot ID
// it cannot know.
// material) would otherwise redirect restore and deep verify to a
// different snapshot's contents. The exported per-snapshot database holds
// exactly one snapshot row, and a snapshot's remote key is derived from
// that row's ID, so the database is the requested one exactly when its
// sole snapshot hashes back to remoteKey. Comparing the requested
// identifier directly would not do: it may be a remote-key prefix a
// recovery host uses in place of a human snapshot ID it cannot know.
func (v *Vaultik) verifySnapshotDBIdentity(
db *database.DB, requested, remoteKey string,
) error {
-115
View File
@@ -1,115 +0,0 @@
package vaultik
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"text/tabwriter"
"time"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/ui"
)
// 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), ui.QuoteUnprintable(entry.Path))
if err != nil {
return err
}
}
return w.Flush()
}
-197
View File
@@ -1,197 +0,0 @@
package vaultik_test
import (
"bytes"
"context"
"encoding/json"
"path/filepath"
"strconv"
"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.
// unprintableFileName is a file name holding a newline and an escape
// sequence, which the table must not print raw.
const unprintableFileName = "odd\nname\x1b[31m"
// backUpForFileListing backs up the fault source tree, plus a file named
// unprintableFileName at its top, 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)
require.NoError(t, afero.WriteFile(fs,
filepath.Join(dataDir, unprintableFileName), []byte("odd"), 0o644))
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")
}
// TestSnapshotFilesTableEscapesAnUnprintablePath checks that the table
// prints a path holding a newline and an escape sequence quoted and
// escaped on one row: the newline must not start a forged row, and the
// escape sequence must not reach the terminal.
func TestSnapshotFilesTableEscapesAnUnprintablePath(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, "", false))
text := out.String()
assert.Contains(t, text,
strconv.Quote(filepath.Join(dataDir, unprintableFileName)))
assert.NotContains(t, text, "\x1b", "no escape byte may reach the output")
for line := range strings.SplitSeq(text, "\n") {
assert.False(t, strings.HasPrefix(line, "name"),
"the newline in the name must not start a row: %q", line)
}
}
// 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")
}
+1 -2
View File
@@ -39,8 +39,7 @@ const maxRemoteOnlyRows = 1000
// manifest reads need throttling.
const remoteManifestFetchConcurrency = 8
// tabPadding is the tabwriter cell padding for the `snapshot list` and
// `snapshot files` tables.
// tabPadding is the tabwriter cell padding for the snapshot table.
const tabPadding = 3
// ListSnapshots prints the table of snapshots known to this host: the