Author SHA1 Message Date
sneak 141ca1c68a snapshot remove accepts a remote-only snapshot's remote key (closes #400)
check / check (push) Successful in 11m8s
A host with no local index could remove a remote-only snapshot only by
its plain snapshot ID, which it does not have. snapshot remove passed
its argument through RemoteSnapshotKey, rehashing a remote key so it
never matched. It now resolves a remote key, or an unambiguous leading
part of it, with the same resolveSnapshotRemoteKey that verify and
restore use. The prompt, the dry run and the warning after a failed
remote delete name the snapshot it resolved to: its ID when known,
otherwise its full remote key. Under --json, snapshot_id holds the ID
only when it is known, and remote_key holds the full key.

Model: deepseek-v4-pro (change); opus-5-5 (rebase onto next, rework)
2026-11-11 06:10:30 +00:00
clawbot 5f1f8c4f66 Tell the user to set the S3 keys before an s3:// storage_url (closes #428)
check / check (push) Successful in 12m48s
config set storage_url to an s3:// URL was refused, without saying why, when s3.access_key_id and s3.secret_access_key were not yet set. The refusal now names both keys and says to set them first. config set still refuses any change that leaves the config unable to load, and clearing a key gets the plain refusal.

Model: opus-5-5
2026-11-11 06:48:46 +01:00
clawbot e8ab13b526 Name the setting and value when a size setting does not parse (closes #429)
check / check (push) Failing after 19m36s
A non-numeric chunk_size or blob_size_limit failed with an error that named neither the setting nor the value. Every size setting now fails at load with its name and the quoted value, for example chunk_size: "banana" is not a size; use a number of bytes or a unit such as 10MB. A value too large for a size says so, with the setting name.

Model: opus-5-5
2026-11-11 06:42:45 +01:00
clawbot f6df500ba2 Refuse a relative index_path (closes #430)
check / check (push) Successful in 10m28s
A relative index_path, or VAULTIK_INDEX_PATH, was accepted and resolved against whatever directory vaultik ran in. Loading the config now refuses a relative or empty value, quoting it, with the same wording as the file:// check; ~ is still expanded first.

Model: opus-5-5
2026-11-11 06:06:54 +01:00
clawbot 7aae6e4ff3 Skip vaultik's own index and PID file in a backup (closes #421)
check / check (push) Successful in 10m54s
The default config backs up the home directory, which holds vaultik's local index and PID file, so a backup stored a torn copy of the index it was writing. The scan now skips the index, its SQLite side files and the PID file whatever the config says, including when index_path or its directory is a symlink. database purge removes the same list of index files.

Model: opus-5-5
2026-11-11 05:42:16 +01:00
clawbot d6eed26482 Report the Go build info version when linker flags set none (closes #422)
check / check (push) Successful in 10m44s
A binary from go install, the README's first command, reported itself as version dev, a development build, although Go had recorded the tag it was built from. When the linker flags leave the version, commit or commit date unset, they are now filled in from the Go build info, so such a binary reports its tag and counts as a release. Builds through make, script/docker and goreleaser are unchanged.

Model: opus-5-5
2026-11-11 05:35:53 +01:00
clawbot cdcbe8adfd Do not log an upload stopped by Ctrl-C as an error (closes #434)
check / check (push) Successful in 11m33s
Pressing Ctrl-C or sending SIGTERM during a backup logged the interrupted blob upload as an ERROR before the interrupted message. The upload step now skips that record when the backup's context has been cancelled, whatever error the storage returned. An upload that fails for any other reason is still logged.

Model: opus-5-5
2026-11-11 05:30:02 +01:00
clawbot 1a478ef275 Say plainly when the private key cannot decrypt a snapshot (closes #427)
check / check (push) Failing after 24m25s
With a private key the backup was not encrypted to, restore and deep verify failed with age's "no identity matched any of the recipients" behind a doubled prefix. They now say in one sentence that the key from VAULTIK_AGE_SECRET_KEY or age_secret_key is not one of the keys the snapshot, or a blob, was encrypted to. A blob reused after a recipient change gets the same message.

Model: opus-5-5
2026-11-11 05:24:30 +01:00
31 changed files with 1435 additions and 88 deletions

No files matched your search

+28 -9
View File
@@ -448,6 +448,8 @@ remove the snapshot's metadata from it (`vaultik prune` does not). For a
snapshot that is only on the destination store, that failure is an error. To
wipe everything on the destination in one go, use `vaultik remote nuke --force`.
- Accepts a snapshot ID, or — for a snapshot only on the destination store — its
remote key (or an unambiguous leading part of it) as shown by `snapshot list`.
- `--local-only`: Skip remote cleanup; only touch the local index
- `--dry-run`: Show what would be deleted without deleting
- `--force`: Skip confirmation prompt
@@ -1039,20 +1041,37 @@ is `git describe --tags --always --dirty`, which `script/version` runs for the
A `docker build .` of a clone, with no build arguments, runs the same
`git describe` (without `--dirty`) on the `.git` in its build context, so it
stamps the same value for a clean commit; the build fails if the context carries
`.git` and no version, commit or commit date comes out. A binary built without
git metadata reports `dev`, or `unknown` when `script/docker` or
`script/cibuild` built it outside a git checkout.
`.git` and no version, commit or commit date comes out. `make` outside a git
checkout and `docker build .` without `.git` both stamp `dev`. `script/docker`
and `script/cibuild` stamp `unknown` when they run outside a git checkout.
A binary built by plain `go install` or `go build` carries no stamped version.
It reports the version Go recorded in it instead:
- a `go install` of the tag `v1.0.0` → `v1.0.0`.
- a `go build` of an untagged commit → a Go pseudo-version, such as
`v1.0.1-0.20261110231226-0123456789ab`.
- a build where Go recorded no version → `dev`.
`goreleaser` stamps a release binary with the tag minus its leading `v`, so the
tag `v1.0.0` produces `vaultik 1.0.0`, matching the archive name
`vaultik_1.0.0_linux_amd64.tar.gz`. `goreleaser --snapshot` stamps
`dev-<12 chars of the commit sha>` rather than inventing the next patch number.
`vaultik version` calls a build a development build when its version is `dev`,
`unknown`, `dev-<sha>`, the short commit sha or `<tag>-<N>-g<short sha>`, with
or without `-dirty`; only a plain tag, such as `v1.0.0` or `1.0.0`, is a
release. If `script/version` cannot be run at all, `make` stops with an error
instead of building an unversioned binary, and a binary that somehow carries an
empty version string still reports itself as a development build.
`vaultik version` calls a build a development build when its version is one of
these:
- `dev` or `unknown`.
- `dev-<sha>`.
- the short commit sha.
- `<tag>-<N>-g<short sha>`.
- a Go pseudo-version.
- any version with a `-dirty` or `+dirty` suffix.
Only a plain tag, such as `v1.0.0` or `1.0.0`, is a release. If `script/version`
cannot be run at all, `make` stops with an error instead of building an
unversioned binary. A binary stamped with an empty version reports the version
Go recorded in it; if Go recorded none, it is a development build.
### cutting a release
+1
View File
@@ -295,6 +295,7 @@ storage_url: "rclone://myremote/path/to/backups"
# Path to local SQLite index database
# This database tracks file state for incremental backups
# It must be an absolute path; ~ is expanded
# Default: the platform data directory, e.g.
# macOS: ~/Library/Application Support/vaultik/index.sqlite
# Linux: ~/.local/share/vaultik/index.sqlite
+1
View File
@@ -24,6 +24,7 @@ require (
github.com/stretchr/testify v1.11.1
go.uber.org/dig v1.19.0
go.uber.org/fx v1.24.0
golang.org/x/mod v0.29.0
golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0
+1 -4
View File
@@ -7,11 +7,9 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"sync"
"time"
"github.com/adrg/xdg"
"github.com/spf13/cobra"
"go.uber.org/dig"
"go.uber.org/fx"
@@ -351,8 +349,7 @@ func runVaultikApp(
// only one runs at a time; a read-only command runs without it and is not
// blocked while a mutator holds the lock (opts.Mode).
func RunWithApp(ctx context.Context, opts AppOptions) error {
release, err := acquireLockIfMutating(opts.Mode,
filepath.Join(xdg.DataHome, "vaultik"))
release, err := acquireLockIfMutating(opts.Mode, pidlock.Dir())
if err != nil {
return err
}
+24 -2
View File
@@ -49,8 +49,14 @@ var (
errWouldNotLoad = errors.New(
"vaultik would not load the config after this change, " +
"so the file was not changed")
errSetS3KeysFirst = errors.New(
"set s3.access_key_id and s3.secret_access_key first, " +
"then storage_url")
)
// storageURLKey is the config key that selects the storage backend.
const storageURLKey = "storage_url"
// macOS is the value of runtime.GOOS on macOS.
const macOS = "darwin"
@@ -276,7 +282,8 @@ storage_url: ""
# Hostname used in snapshot IDs. Default: system hostname.
# hostname: myserver
# Path to the local SQLite index database.
# Path to the local SQLite index database. It must be an absolute path;
# ~ is expanded.
# Default: the platform data directory, e.g.
# macOS: ~/Library/Application Support/vaultik/index.sqlite
# Linux: ~/.local/share/vaultik/index.sqlite
@@ -512,7 +519,7 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
err = checkConfigChange(oldData, data, settingData)
if err != nil {
return fmt.Errorf("%w: %w", errWouldNotLoad, err)
return wouldNotLoadError(key, err)
}
err = os.WriteFile(path, data, configFileMode)
@@ -536,6 +543,21 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
return nil
}
// wouldNotLoadError returns the refusal of a change to key that vaultik
// would not load for reason. When storage_url moves to s3:// before the S3
// credentials are set, it says to set them first: they can be set while
// the old storage_url is still in place.
func wouldNotLoadError(key string, reason error) error {
if key == storageURLKey &&
(errors.Is(reason, config.ErrS3KeyIDRequired) ||
errors.Is(reason, config.ErrS3SecretRequired)) {
return fmt.Errorf("%w: %w; %w", errWouldNotLoad, reason,
errSetS3KeysFirst)
}
return fmt.Errorf("%w: %w", errWouldNotLoad, reason)
}
// checkConfigChange returns the reason vaultik would not load newData, the
// config after a change, if the change brings that failure in. If oldData,
// the config before it, loads, the new one must load too. A config that
+94
View File
@@ -2,6 +2,7 @@ package cli //nolint:testpackage // exercises unexported yamlPathGet/yamlPathSet
import (
"bytes"
"errors"
"os"
"path/filepath"
"strconv"
@@ -152,6 +153,21 @@ snapshots:
- "~"
`
// testS3URL is an S3 storage_url, which needs credentials to load.
const testS3URL = "s3://bucket/prefix?endpoint=https://s3.example.com"
// configWithS3Keys loads: its storage_url is testS3URL and both S3 keys are
// set.
const configWithS3Keys = storageURLKey + ": " + testS3URL + `
s3:
access_key_id: AKIAEXAMPLE
secret_access_key: example-secret
snapshots:
home:
paths:
- "~"
`
// configWithUnsetReference loads only where VAULTIK_TEST_UNSET_SECRET is
// set, as a backup job's environment might set it. No test sets it.
const configWithUnsetReference = configWithoutRecipients + `s3:
@@ -258,6 +274,14 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
"compression_level must be between 1 and 19"},
{configWithoutRecipients, "chunk_size", "1KB",
"chunk_size must be at least 1MB"},
// An s3:// URL before its credentials names them.
{configWithoutRecipients, storageURLKey, testS3URL,
"s3.access_key_id is required for s3:// URLs; " +
"set s3.access_key_id and s3.secret_access_key first"},
{configWithoutRecipients + "s3:\n access_key_id: AKIAEXAMPLE\n",
storageURLKey, testS3URL,
"s3.secret_access_key is required for s3:// URLs; " +
"set s3.access_key_id and s3.secret_access_key first"},
// With its reference filled in, the config loads.
{levelReference, "blob_size_limit", "1MB",
"blob_size_limit must be at least"},
@@ -343,6 +367,76 @@ func TestConfigSetRefusesOnlyItsOwnFailure(t *testing.T) {
}
}
// TestConfigSetS3KeysBeforeStorageURL checks that the order the refusal
// gives works on a config that loads with a file:// storage_url.
func TestConfigSetS3KeysBeforeStorageURL(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(configWithoutRecipients), configFileMode)
if err != nil {
t.Fatalf("write config: %v", err)
}
for _, set := range [][2]string{
{"s3.access_key_id", "AKIAEXAMPLE"},
{"s3.secret_access_key", "example-secret"},
{storageURLKey, testS3URL},
} {
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
path, set[0], set[1])
if err != nil {
t.Fatalf("config set %s: %v", set[0], err)
}
}
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
if err != nil {
t.Fatalf("read config: %v", err)
}
if !strings.Contains(string(data), storageURLKey+": "+testS3URL) {
t.Errorf("config set storage_url wrote:\n%s", data)
}
}
// TestConfigSetRefusesClearingS3Key checks that clearing s3.access_key_id on
// a config with an s3:// storage_url is refused without saying to set the
// S3 keys first. That advice is only for a change to storage_url.
func TestConfigSetRefusesClearingS3Key(t *testing.T) {
t.Parallel()
const key = "s3.access_key_id"
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(configWithS3Keys), configFileMode)
if err != nil {
t.Fatalf("write config: %v", err)
}
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false), path, key, "")
if !errors.Is(err, errWouldNotLoad) {
t.Errorf("clearing %s gives error %v, want one saying %q",
key, err, errWouldNotLoad)
}
if errors.Is(err, errSetS3KeysFirst) {
t.Errorf("clearing %s gives error %v, want one not saying %q",
key, err, errSetS3KeysFirst)
}
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(data) != configWithS3Keys {
t.Errorf("clearing %s changed the file to:\n%s", key, data)
}
}
// TestConfigSetWritesEnvSection checks that config set writes a key under
// the top-level env section, whose keys are not settings, on a config that
// loads.
+9 -10
View File
@@ -6,6 +6,7 @@ import (
"github.com/spf13/cobra"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/vaultik"
)
@@ -100,18 +101,16 @@ Use --force to skip the confirmation prompt.`,
}
}
// Delete the database file
err = os.Remove(dbPath)
if err != nil {
return fmt.Errorf("failed to delete database: %w", err)
// Delete the database file and its side files. A side file
// may not exist, so only failing to delete the database file
// is an error.
for _, indexFile := range database.IndexFiles(dbPath) {
err = os.Remove(indexFile)
if err != nil && indexFile == dbPath {
return fmt.Errorf("failed to delete database: %w", err)
}
}
// Also delete WAL and SHM files if they exist
walPath := dbPath + "-wal"
shmPath := dbPath + "-shm"
_ = os.Remove(walPath) // Ignore errors - files may not exist
_ = os.Remove(shmPath)
out.Infof("Local state database deleted: %s.", dbPath)
log.Info("Local state database deleted", "path", dbPath)
+27
View File
@@ -5,9 +5,11 @@ import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/vaultik"
)
@@ -46,3 +48,28 @@ func TestDatabaseDeleteDeclinedIsError(t *testing.T) {
out.String())
}
}
// TestDatabaseDeleteRemovesSideFiles checks that the side files SQLite
// keeps beside the database file are deleted with it.
//
//nolint:paralleltest // mutates the process-global rootFlags
func TestDatabaseDeleteRemovesSideFiles(t *testing.T) {
dir := t.TempDir()
dbPath, cfgPath := seedDatabaseDeleteConfig(t, dir)
indexFiles := database.IndexFiles(dbPath)
for _, indexFile := range indexFiles {
seedFile(t, dir, filepath.Base(indexFile), "data")
}
setRootFlags(t, RootFlags{ConfigPath: cfgPath})
mustExecute(t, newDatabaseDeleteCommand(), "--force")
for _, indexFile := range indexFiles {
_, err := os.Stat(indexFile)
if !os.IsNotExist(err) {
t.Errorf("database delete left %s: stat err = %v", indexFile, err)
}
}
}
+3
View File
@@ -21,6 +21,8 @@ const shortCommitLen = 12
const exitCodeInterrupted = 130
// Entry is the main entry point for the CLI application.
// It first calls globals.FillUnsetFromBuildInfo, which fills in the
// version, commit and commit date that the linker flags did not set.
// It prints the startup banner to stderr (unless a banner-suppressing
// flag is present in os.Args — see bannerSuppressedInArgs), executes the
// root cobra command, and routes any returned error through the
@@ -33,6 +35,7 @@ const exitCodeInterrupted = 130
// deferred profile writers run before the process ends. See run in
// cmd/vaultik/main.go.
func Entry() int {
globals.FillUnsetFromBuildInfo()
emitStartupBanner(os.Args[1:], os.Stderr)
rootCmd := NewRootCommand()
@@ -0,0 +1,69 @@
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
import (
"fmt"
"os"
"path/filepath"
"testing"
"github.com/adrg/xdg"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestEntrySnapshotCreateSkipsIndexAndLockFiles backs up a directory
// that holds vaultik's index and PID file beside one other file, with no
// exclude pattern set. The summary counts only the other file. See
// https://git.eeqj.de/sneak/vaultik/issues/421.
//
// Not parallel: it replaces os.Args, os.Stdout, os.Stderr and the xdg
// globals.
func TestEntrySnapshotCreateSkipsIndexAndLockFiles(t *testing.T) {
for _, testCase := range []struct {
name string
// linked sets index_path to a symlink outside the backed-up
// directory that points to the index inside it.
linked bool
}{
{name: "index in the backed-up directory"},
{name: "index_path links into the backed-up directory", linked: true},
} {
t.Run(testCase.name, func(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yml")
sourceDir := filepath.Join(dir, "source")
storeDir := filepath.Join(dir, "store")
indexPath := filepath.Join(sourceDir, "index.sqlite")
if testCase.linked {
link := filepath.Join(dir, "index.sqlite")
require.NoError(t, os.Symlink(indexPath, link))
indexPath = link
}
contents := fmt.Sprintf(hermeticConfig,
sourceDir, storeDir, indexPath)
require.NoError(t,
os.WriteFile(configPath, []byte(contents), configFileMode))
require.NoError(t, os.Mkdir(storeDir, 0o750))
require.NoError(t, os.Mkdir(sourceDir, 0o750))
require.NoError(t, os.WriteFile(
filepath.Join(sourceDir, "file.txt"), []byte("hello"), 0o600))
// The PID file goes in a directory under XDG_DATA_HOME, which
// xdg reads at package init.
t.Setenv("XDG_DATA_HOME", filepath.Join(sourceDir, "data"))
xdg.Reload()
t.Cleanup(xdg.Reload)
code, stdout, _ := runEntry(t,
flagConfig, configPath, cmdSnapshot, cmdCreate)
require.Equal(t, 0, code)
assert.Contains(t, stdout,
"Files: 1 examined, 1 backed up, 0 unchanged.")
})
}
}
+4
View File
@@ -288,6 +288,10 @@ know about), which is what 'vaultik prune' does. The command prints the
'vaultik prune' invocation to run as a follow-up only when it removed the
snapshot's metadata from the destination store, and never under --json.
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).
An ID that names no snapshot in the local index or on the destination
store is an error.
+20 -6
View File
@@ -70,14 +70,22 @@ var (
errFileURLNotAbsolute = errors.New(
"a file:// storage_url needs an absolute path, " +
"as in file:///path/to/backup")
errIndexPathNotAbsolute = errors.New(
"index_path and VAULTIK_INDEX_PATH need an absolute path, " +
"as in ~/.local/share/vaultik/index.sqlite")
errNegativePruneGracePeriod = errors.New(
"prune_grace_period must not be negative")
errStorageNotConfigured = errors.New(
"storage not configured; set storage_url or provide s3.endpoint + " +
"s3.bucket + credentials")
errS3BucketRequired = errors.New("s3.bucket is required (or set storage_url)")
errS3KeyIDRequired = errors.New("s3.access_key_id is required")
errS3SecretRequired = errors.New("s3.secret_access_key is required")
)
// Validate returns these when S3 storage is configured without its
// credentials.
var (
ErrS3KeyIDRequired = errors.New("s3.access_key_id is required")
ErrS3SecretRequired = errors.New("s3.secret_access_key is required")
)
// expandTilde expands ~ at the start of a path to the user's home directory.
@@ -389,6 +397,8 @@ func Parse(data []byte) (*Config, error) {
// create needs a recipient and it checks for one itself
// - At least one snapshot must be configured with at least one path
// - 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
// - Chunk size must be at least 1MB
// - Blob size limit must be at least the largest chunk the chunker can emit
// (chunk_size times chunker.ChunkSizeSpread), so a single-chunk blob never
@@ -422,6 +432,10 @@ func (c *Config) Validate() error {
return err
}
if !filepath.IsAbs(c.IndexPath) {
return fmt.Errorf("%w: %q", errIndexPathNotAbsolute, c.IndexPath)
}
if c.ChunkSize.Int64() < minChunkSize {
return errChunkSizeTooSmall
}
@@ -515,11 +529,11 @@ func (c *Config) validateStorage() error {
}
if c.S3.AccessKeyID == "" {
return errS3KeyIDRequired
return ErrS3KeyIDRequired
}
if c.S3.SecretAccessKey == "" {
return errS3SecretRequired
return ErrS3SecretRequired
}
return nil
@@ -546,11 +560,11 @@ func (c *Config) validateStorageURL() error {
case strings.HasPrefix(c.StorageURL, "s3://"):
// S3 storage needs credentials
if c.S3.AccessKeyID == "" {
return fmt.Errorf("%w for s3:// URLs", errS3KeyIDRequired)
return fmt.Errorf("%w for s3:// URLs", ErrS3KeyIDRequired)
}
if c.S3.SecretAccessKey == "" {
return fmt.Errorf("%w for s3:// URLs", errS3SecretRequired)
return fmt.Errorf("%w for s3:// URLs", ErrS3SecretRequired)
}
return nil
+61
View File
@@ -165,6 +165,7 @@ func TestValidateBlobSizeLimit(t *testing.T) {
AgeRecipients: []string{testSneakAgePublicKey},
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
StorageURL: "file:///tmp/vaultik-test-store",
IndexPath: "/tmp/vaultik-test-index.sqlite",
ChunkSize: chunkSize,
BlobSizeLimit: blobLimit,
CompressionLevel: 3,
@@ -510,6 +511,65 @@ func TestLoadRejectsRelativeFileURL(t *testing.T) {
}
}
// TestLoadIndexPath checks that a relative index_path fails at load, naming
// the value. Accepted, it would resolve against the working directory, so a
// backup run from another directory would start from an empty index. A path
// starting with ~ is expanded and loads.
func TestLoadIndexPath(t *testing.T) {
t.Parallel()
home, err := os.UserHomeDir()
if err != nil {
t.Fatalf("home directory: %v", err)
}
load := func(t *testing.T, indexPath string) (*Config, error) {
t.Helper()
data := "snapshots:\n" +
" test:\n" +
" paths: [/tmp/vaultik-test-source]\n" +
"storage_url: file:///tmp/vaultik-test-storage\n" +
"index_path: " + indexPath + "\n"
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(data), 0o600)
if err != nil {
t.Fatalf("write config: %v", err)
}
return Load(path)
}
t.Run("relative path is rejected", func(t *testing.T) {
t.Parallel()
_, err := load(t, "relative/index.sqlite")
if !errors.Is(err, errIndexPathNotAbsolute) {
t.Fatalf("Load() error = %v, want errIndexPathNotAbsolute", err)
}
if !strings.Contains(err.Error(), `"relative/index.sqlite"`) {
t.Errorf("error %q does not name the value", err.Error())
}
})
t.Run("path starting with ~ is expanded", func(t *testing.T) {
t.Parallel()
cfg, err := load(t, "~/vaultik/index.sqlite")
if err != nil {
t.Fatalf("Load() unexpected error: %v", err)
}
want := filepath.Join(home, "vaultik", "index.sqlite")
if cfg.IndexPath != want {
t.Errorf("IndexPath = %q, want %q", cfg.IndexPath, want)
}
})
}
// TestValidateAgeRecipients checks that recipients are parsed at config load
// (a bad entry fails immediately, not mid-backup) and that no invalid entry —
// least of all a pasted secret key — is echoed in the error. An empty list
@@ -522,6 +582,7 @@ func TestValidateAgeRecipients(t *testing.T) {
AgeRecipients: recipients,
Snapshots: map[string]SnapshotConfig{"test": {Paths: []string{"/tmp/src"}}},
StorageURL: "file:///tmp/vaultik-test-store",
IndexPath: "/tmp/vaultik-test-index.sqlite",
ChunkSize: Size(10 * 1024 * 1024),
BlobSizeLimit: Size(10 * 1024 * 1024 * 1024),
CompressionLevel: 3,
+16 -5
View File
@@ -39,6 +39,15 @@ 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
@@ -63,8 +72,10 @@ func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
func checkSizes(node *yaml.Node, keys []string) error {
typ, err := SettingType(keys)
if err != nil {
// Unknown keys are refused before this check, so this is the env
// section, which holds no settings.
// 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
}
@@ -127,14 +138,14 @@ func (s Size) String() string {
// ParseSize parses a size string, such as "10MB", into a Size value. An
// error quotes s.
func ParseSize(s string) (Size, error) {
bytes, err := humanize.ParseBytes(s)
bytes, err := humanize.ParseBigBytes(s)
if err != nil {
return 0, fmt.Errorf("%q %w", s, errNotASize)
}
if bytes > math.MaxInt64 {
if !bytes.IsInt64() {
return 0, fmt.Errorf("%w: %s", errSizeTooLarge, s)
}
return Size(bytes), nil
return Size(bytes.Int64()), nil
}
+32 -1
View File
@@ -1,4 +1,4 @@
package config //nolint:testpackage // checks the unexported errNotASize
package config //nolint:testpackage // checks errNotASize and errSizeTooLarge
import (
"errors"
@@ -58,6 +58,37 @@ func TestParseRefusesValueThatIsNotASize(t *testing.T) {
}
}
// 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) {
+11
View File
@@ -20,6 +20,17 @@ const indexDirPerm = 0o700
// backed-up path and chunk hash and must stay private.
const indexFilePerm = 0o600
// IndexFiles returns the path of the index at indexPath and the paths of
// the side files SQLite keeps beside it while it writes.
func IndexFiles(indexPath string) []string {
return []string{
indexPath,
indexPath + "-wal",
indexPath + "-shm",
indexPath + "-journal",
}
}
// ensureIndexFileMode makes the index file owner-only before the SQLite
// driver opens it: it creates the file 0600 if absent, or chmods an existing
// one to 0600. Doing this first matters because SQLite creates its -wal and
+62 -6
View File
@@ -1,19 +1,24 @@
// Package globals holds application-wide metadata (name, version,
// commit) that is populated at build time via linker flags.
// commit) that is populated at build time via linker flags, or from the
// build info Go records in the binary when the linker flags set none.
package globals
import (
"regexp"
"runtime/debug"
"strings"
"time"
"golang.org/x/mod/module"
)
// Appname is the application name, populated from main().
var Appname = "vaultik" //nolint:gochecknoglobals // set via -ldflags at build time
// DevVersion is the version a binary reports when it was built without
// git metadata: script/version emits it outside a git checkout, and an
// unstamped `go build` keeps it. goreleaser's snapshot template stamps
// git metadata: script/version emits it outside a git checkout, and a
// binary keeps it when neither its linker flags nor its Go build info
// name a version. goreleaser's snapshot template stamps
// it followed by "-" and the commit it was built from. It is
// deliberately not a number.
const DevVersion = "dev"
@@ -32,6 +37,54 @@ var Commit = Unknown //nolint:gochecknoglobals // set via -ldflags at build time
// CommitDate is the ISO-8601 date of the commit, populated from main().
var CommitDate = Unknown //nolint:gochecknoglobals // set via -ldflags at build time
// develVersion is the main module version Go records when it cannot
// tell which version it built.
const develVersion = "(devel)"
// FillUnsetFromBuildInfo applies FillUnset to Version, Commit and
// CommitDate, with the build info of the running binary.
func FillUnsetFromBuildInfo() {
info, ok := debug.ReadBuildInfo()
if !ok {
return
}
Version, Commit, CommitDate = FillUnset(
info, Version, Commit, CommitDate)
}
// FillUnset returns version, commit and commitDate. Each one that
// still holds its default ("dev" or empty for the version, "unknown"
// for the others) is replaced by the value Go recorded in info. A
// `go install` of a tag sets no linker flags, but Go records the tag
// as the main module's version. Go records the commit and its time
// only when it builds from a git checkout. The time is cut to a date,
// as the Makefile stamps it.
func FillUnset(
info *debug.BuildInfo, version, commit, commitDate string,
) (string, string, string) {
if (version == "" || version == DevVersion) &&
info.Main.Version != "" && info.Main.Version != develVersion {
version = info.Main.Version
}
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
if commit == Unknown {
commit = s.Value
}
case "vcs.time":
t, err := time.Parse(time.RFC3339, s.Value)
if commitDate == Unknown && err == nil {
commitDate = t.UTC().Format(time.DateOnly)
}
}
}
return version, commit, commitDate
}
// Author identifies the upstream author of vaultik.
const Author = "Jeffrey Paul <sneak@sneak.berlin>"
@@ -68,8 +121,10 @@ func New() (*Globals, error) {
// a release. "dev" and goreleaser's snapshot "dev-<sha>" count, and so
// does what `git describe --tags --always --dirty` gives a make or
// docker build of an untagged commit: the bare short commit, or
// tag-N-gHASH on a commit after a tag. Any version ending in "-dirty"
// counts, a modified checkout of a tag ("v1.0.0-dirty") included.
// tag-N-gHASH on a commit after a tag. A Go pseudo-version counts: Go
// records one for a build of an untagged commit without linker flags.
// Any version ending in "-dirty" or "+dirty" counts, a modified
// checkout of a tag ("v1.0.0-dirty", "v1.0.0+dirty") included.
// A plain tag such as "v1.0.0" or "1.0.0" is a release.
//
// The empty string counts too. Nothing that knows its version reports
@@ -82,7 +137,8 @@ func New() (*Globals, error) {
func IsDevVersion(v string) bool {
if v == "" || v == Unknown || v == DevVersion ||
strings.HasPrefix(v, DevVersion+"-") ||
strings.HasSuffix(v, "-dirty") {
strings.HasSuffix(v, "-dirty") || strings.HasSuffix(v, "+dirty") ||
module.IsPseudoVersion(v) {
return true
}
+113 -4
View File
@@ -1,6 +1,7 @@
package globals_test
import (
"runtime/debug"
"testing"
"sneak.berlin/go/vaultik/internal/globals"
@@ -59,12 +60,20 @@ func TestIsDevVersion(t *testing.T) {
{"1.0.0-rc.1-12-g877eb2f", true},
// A tagged commit with uncommitted changes is not that tag.
{"v1.0.0-dirty", true},
// What Go records for a build without linker flags: a
// pseudo-version for an untagged commit, "+dirty" for a
// modified checkout.
{"v1.0.0-rc3.0.20261111001226-abcdefabcdef", true},
{"v0.0.0-20261111001226-abcdefabcdef", true},
{"v1.0.1-0.20261111001226-abcdefabcdef", true},
{"v1.0.0+dirty", true},
// What a tagged build produces (goreleaser's .Version strips
// the leading "v"; script/version keeps it).
{"1.0.0", false},
{"0.1.0", false},
{"1.0.0-rc.1", false},
{"v1.0.0", false},
{"v1.0.0-rc3", false},
// A release must not be mistaken for a dev build just because
// the string happens to contain "dev".
{"1.0.0-dev", false},
@@ -86,10 +95,9 @@ func TestIsDevVersion(t *testing.T) {
}
}
// TestDefaultVersionIsDev pins the linker-flag contract: an unstamped
// binary (no -ldflags at all, which is what `go build ./...` and `go
// install` produce) must report itself as a development build rather
// than as some default release number.
// TestDefaultVersionIsDev pins the default: a binary whose linker flags
// and build info name no version must report itself as a development
// build rather than as some default release number.
func TestDefaultVersionIsDev(t *testing.T) {
t.Parallel()
@@ -98,3 +106,104 @@ func TestDefaultVersionIsDev(t *testing.T) {
globals.DevVersion)
}
}
// buildInfo returns build info with the given main module version and,
// when revision is not empty, the commit settings Go records for a
// build from a git checkout.
func buildInfo(version, revision string) *debug.BuildInfo {
info := &debug.BuildInfo{
Main: debug.Module{Path: "sneak.berlin/go/vaultik", Version: version},
}
if revision != "" {
info.Settings = []debug.BuildSetting{
{Key: "vcs.revision", Value: revision},
{Key: "vcs.time", Value: "2026-11-10T23:12:26Z"},
}
}
return info
}
// TestFillUnset covers a binary from `go install` of a tag, which
// carries no linker flags: it must report the tag. Values the linker
// flags set are kept.
func TestFillUnset(t *testing.T) {
t.Parallel()
const (
tag = "v1.0.0-rc3"
rev = "0123456789abcdef0123456789abcdef01234567"
)
cases := []struct {
name string
info *debug.BuildInfo
version, commit, date string
wantVersion, wantCommit string
wantDate string
}{
{
name: "go install of a tag",
info: buildInfo(tag, ""),
version: globals.DevVersion,
commit: globals.Unknown,
date: globals.Unknown,
wantVersion: tag,
wantCommit: globals.Unknown,
wantDate: globals.Unknown,
},
{
name: "empty version",
info: buildInfo(tag, ""),
version: "",
commit: globals.Unknown,
date: globals.Unknown,
wantVersion: tag,
wantCommit: globals.Unknown,
wantDate: globals.Unknown,
},
{
name: "go build in a git checkout",
info: buildInfo(tag, rev),
version: globals.DevVersion,
commit: globals.Unknown,
date: globals.Unknown,
wantVersion: tag,
wantCommit: rev,
wantDate: "2026-11-10",
},
{
name: "Go could not tell the version",
info: buildInfo("(devel)", ""),
version: globals.DevVersion,
commit: globals.Unknown,
date: globals.Unknown,
wantVersion: globals.DevVersion,
wantCommit: globals.Unknown,
wantDate: globals.Unknown,
},
{
name: "linker flags set",
info: buildInfo(tag, rev),
version: "v1.0.0-rc2-4-gabcdef1",
commit: "abcdef1",
date: "2026-11-01",
wantVersion: "v1.0.0-rc2-4-gabcdef1",
wantCommit: "abcdef1",
wantDate: "2026-11-01",
},
}
for _, tc := range cases {
version, commit, date := globals.FillUnset(
tc.info, tc.version, tc.commit, tc.date)
if version != tc.wantVersion || commit != tc.wantCommit ||
date != tc.wantDate {
t.Errorf("%s: got %q, %q, %q; want %q, %q, %q", tc.name,
version, commit, date,
tc.wantVersion, tc.wantCommit, tc.wantDate)
}
}
}
+14 -1
View File
@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"github.com/adrg/xdg"
"golang.org/x/sys/unix"
)
@@ -29,8 +30,20 @@ const (
lockDirPerm = 0o700
// pidFilePerm is the mode for the PID file (owner-only).
pidFilePerm = 0o600
// pidFileName is the name of the PID file in the lock directory.
pidFileName = "vaultik.pid"
)
// Dir returns the directory that holds vaultik's PID file.
func Dir() string {
return filepath.Join(xdg.DataHome, "vaultik")
}
// Path returns the path of the PID file that Acquire uses in lockDir.
func Path(lockDir string) string {
return filepath.Join(lockDir, pidFileName)
}
// Acquire attempts to acquire a PID lock in the specified directory.
// If another process holds the lock, it returns ErrAlreadyRunning with
// that process's PID. On success, it writes the current PID to the lock
@@ -42,7 +55,7 @@ func Acquire(lockDir string) (*Lock, error) {
return nil, fmt.Errorf("creating lock directory: %w", err)
}
lockPath := filepath.Join(lockDir, "vaultik.pid")
lockPath := Path(lockDir)
// No O_TRUNC: the file may hold the PID of the process that has the
// lock, which the error below reports.
@@ -0,0 +1,65 @@
package snapshot_test
import (
"context"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/snapshot"
)
// A backup of the directory that holds vaultik's index and PID file
// stores neither, with no exclude pattern set, and still stores the
// files beside them. See https://git.eeqj.de/sneak/vaultik/issues/421.
func TestScanSkipsIndexAndLockFiles(t *testing.T) {
t.Parallel()
const dataDir = "/home/user/.local/share/vaultik"
indexAndLockFiles := append(database.IndexFiles(dataDir+"/index.sqlite"),
dataDir+"/vaultik.pid")
fs := afero.NewMemMapFs()
for _, p := range append(indexAndLockFiles, dataDir+"/notes.txt") {
require.NoError(t, afero.WriteFile(fs, p, []byte("data"), 0o600))
}
db, err := database.NewTestDB()
require.NoError(t, err)
defer func() { _ = db.Close() }()
repos := database.NewRepositories(db)
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
FS: fs,
ChunkSize: 64 * 1024,
Repositories: repos,
MaxBlobSize: 1024 * 1024,
CompressionLevel: 3,
AgeRecipients: []string{
"age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"},
IndexAndLockFiles: indexAndLockFiles,
})
require.NotNil(t, scanner)
ctx := context.Background()
createSnapshotRecord(ctx, t, repos, "test-snapshot")
result, err := scanner.Scan(ctx, "/home/user", "test-snapshot")
require.NoError(t, err)
require.Equal(t, 1, result.FilesScanned)
stored, err := repos.Files.GetByPath(ctx, dataDir+"/notes.txt")
require.NoError(t, err)
require.NotNil(t, stored, "a file beside the index is stored")
for _, p := range indexAndLockFiles {
f, err := repos.Files.GetByPath(ctx, p)
require.NoError(t, err)
require.Nil(t, f, "%s is not stored", p)
}
}
+45
View File
@@ -2,11 +2,13 @@ package snapshot
import (
"os"
"path/filepath"
"github.com/spf13/afero"
"go.uber.org/fx"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/pidlock"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/ui"
)
@@ -62,9 +64,52 @@ func provideScannerFactory(
EnableProgress: params.EnableProgress,
UI: params.UI,
Exclude: excludes,
IndexAndLockFiles: indexAndLockFiles(cfg.IndexPath, pidlock.Dir()),
SkipErrors: params.SkipErrors,
PreviousSnapshotID: params.PreviousSnapshotID,
DeviceNumber: params.DeviceNumber,
})
}
}
// indexAndLockFiles returns the paths of the index at indexPath, its side
// files and the PID file in lockDir. Symlinks in their directories are
// resolved, as they are in the snapshot's source paths, so the scan finds
// them under the same names. If indexPath is a symlink, the file it points
// to and that file's side files are returned too.
func indexAndLockFiles(indexPath, lockDir string) []string {
paths := append(database.IndexFiles(indexPath), pidlock.Path(lockDir))
for i, p := range paths {
paths[i] = resolveParentDir(p)
}
// SQLite writes the file a symlinked index points to and keeps the
// side files beside that file. The database package creates the index
// before the scanner is built, so the link resolves.
index := resolveParentDir(indexPath)
target, err := filepath.EvalSymlinks(index)
if err == nil && target != index {
paths = append(paths, database.IndexFiles(target)...)
}
return paths
}
// resolveParentDir returns the absolute form of path with the symlinks in
// its parent directory resolved. If the parent directory cannot be
// resolved, it returns the absolute form unresolved. If path has no
// absolute form, it returns path.
func resolveParentDir(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
return path
}
dir, err := filepath.EvalSymlinks(filepath.Dir(abs))
if err != nil {
return abs
}
return filepath.Join(dir, filepath.Base(abs))
}
+32
View File
@@ -0,0 +1,32 @@
//nolint:testpackage // exercises the unexported indexAndLockFiles helper
package snapshot
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
// The scan walks source paths with symlinks resolved, so vaultik's own
// files are named the same way, or a symlinked home would back them up.
func TestIndexAndLockFilesResolvesSymlinkedDirectory(t *testing.T) {
t.Parallel()
realDir, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
link := filepath.Join(t.TempDir(), "link")
require.NoError(t, os.Symlink(realDir, link))
paths := indexAndLockFiles(filepath.Join(link, "index.sqlite"), link)
require.Equal(t, []string{
filepath.Join(realDir, "index.sqlite"),
filepath.Join(realDir, "index.sqlite-wal"),
filepath.Join(realDir, "index.sqlite-shm"),
filepath.Join(realDir, "index.sqlite-journal"),
filepath.Join(realDir, "vaultik.pid"),
}, paths)
}
+27 -3
View File
@@ -67,6 +67,9 @@ type Scanner struct {
currentSourcePath string
exclude []string // Glob patterns for files/directories to exclude
compiledExclude []compiledPattern // Compiled glob patterns
// indexAndLockFiles holds the paths of vaultik's own working files,
// which the scan treats as excluded (ScannerConfig.IndexAndLockFiles).
indexAndLockFiles map[string]struct{}
progress *ProgressReporter
// skipErrors skips files that cannot be opened or read (logged loudly);
// packer, database, encryption, and upload errors still abort the run.
@@ -133,6 +136,11 @@ type ScannerConfig struct {
EnableProgress bool // Enable the live progress reporter (ETAs, throughput)
UI *ui.Writer // Where user-facing scanner messages go; nil = discard
Exclude []string // Glob patterns for files/directories to exclude
// IndexAndLockFiles are the absolute paths of vaultik's own working
// files: the index, its side files and the PID file. The scan treats
// them as excluded, whatever Exclude says, since a copy taken while
// they are being written is torn.
IndexAndLockFiles []string
// SkipErrors skips files that cannot be opened or read (log loudly but
// continue); packer, database, encryption, and upload errors still abort.
SkipErrors bool
@@ -226,6 +234,11 @@ func NewScanner(cfg ScannerConfig) *Scanner {
deviceNumber = deviceNumberFromStat
}
indexAndLockFiles := make(map[string]struct{}, len(cfg.IndexAndLockFiles))
for _, p := range cfg.IndexAndLockFiles {
indexAndLockFiles[filepath.Clean(p)] = struct{}{}
}
return &Scanner{
fs: cfg.FS,
chunker: chunker.NewChunker(cfg.ChunkSize),
@@ -237,6 +250,7 @@ func NewScanner(cfg ScannerConfig) *Scanner {
ageRecipient: strings.Join(cfg.AgeRecipients, ","),
exclude: cfg.Exclude,
compiledExclude: compiledExclude,
indexAndLockFiles: indexAndLockFiles,
progress: progress,
skipErrors: cfg.SkipErrors,
previousSnapshotID: cfg.PreviousSnapshotID,
@@ -1861,7 +1875,11 @@ func (s *Scanner) uploadBlobIfNeeded(
err = s.storage.PutWithProgress(ctx, blobPath, blobWithReader.Reader,
finishedBlob.Compressed, progressCallback)
if err != nil {
log.Error("Failed to upload blob", "hash", finishedBlob.Hash, "error", err)
// 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.Error("Failed to upload blob", "hash", finishedBlob.Hash, "error", err)
}
return 0, false, fmt.Errorf("uploading blob to storage: %w", err)
}
@@ -2248,8 +2266,9 @@ func (s *Scanner) detectDeletedFiles(
// excludedOrInExcludedDir reports whether filePath, which is at or
// beneath rootPath, or any directory between rootPath and it, matches an
// exclude pattern. A pattern such as */.local/share/Trash matches the
// directory but not the files inside it, which the walk skips too.
// exclude pattern or is one of vaultik's own files. A pattern such as
// */.local/share/Trash matches the directory but not the files inside it,
// which the walk skips too.
func (s *Scanner) excludedOrInExcludedDir(filePath, rootPath string) bool {
for p := filePath; len(p) > len(rootPath); p = filepath.Dir(p) {
if s.shouldExclude(p, rootPath) {
@@ -2373,9 +2392,14 @@ func doubleStarVariants(pattern string) []string {
}
// shouldExclude checks if a path should be excluded based on exclude patterns
// or because it is one of vaultik's own working files.
// filePath is the full path to the file
// rootPath is the root of the backup source directory
func (s *Scanner) shouldExclude(filePath, rootPath string) bool {
if _, ok := s.indexAndLockFiles[filePath]; ok {
return true
}
if len(s.compiledExclude) == 0 {
return false
}
+135
View File
@@ -0,0 +1,135 @@
//nolint:testpackage // exercises the unexported uploadBlobIfNeeded
package snapshot
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sneak.berlin/go/vaultik/internal/blob"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/ui"
)
const uploadFailedLogMessage = "Failed to upload blob"
// uploadTestBlob uploads a small blob through store.
func uploadTestBlob(ctx context.Context, store storage.Storer) error {
data := []byte("blob data")
s := &Scanner{storage: store, ui: ui.NewWithColor(io.Discard, false)}
_, _, err := s.uploadBlobIfNeeded(ctx, "blobs/test", &blob.WithReader{
FinishedBlob: &blob.FinishedBlob{
Hash: "test",
Compressed: int64(len(data)),
},
Reader: bytes.NewReader(data),
}, time.Now(), &ScanResult{})
return err
}
// captureLog runs fn and returns what the logger wrote to stderr
// 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()
stderrPath := filepath.Join(t.TempDir(), "stderr")
stderrFile, err := os.Create(stderrPath) //nolint:gosec // G304: test temp path
if err != nil {
t.Fatalf("creating stderr file: %v", err)
}
previous := os.Stderr
os.Stderr = stderrFile
log.Initialize(log.Config{})
fn()
os.Stderr = previous
log.Initialize(log.Config{})
_ = stderrFile.Close()
captured, err := os.ReadFile(stderrPath) //nolint:gosec // G304: test temp path
if err != nil {
t.Fatalf("reading stderr file: %v", err)
}
return string(captured)
}
// An upload stopped by Ctrl-C or SIGTERM is not logged as an error; the
// interrupted message is all the user sees. See
// https://git.eeqj.de/sneak/vaultik/issues/434.
//
//nolint:paralleltest // captureLog replaces os.Stderr
func TestInterruptedUploadIsNotLoggedAsError(t *testing.T) {
inner, err := storage.NewFileStorer(t.TempDir())
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
// The user interrupts mid-upload. The injected error does not wrap
// context.Canceled, so only ctx shows why the upload failed.
store := faultstore.New(inner)
store.OnPut = func(string) faultstore.PutAction {
cancel()
return faultstore.PutFail
}
var uploadErr error
stderr := captureLog(t, func() { uploadErr = uploadTestBlob(ctx, store) })
if uploadErr == nil {
t.Fatal("expected the upload to fail")
}
if strings.Contains(stderr, uploadFailedLogMessage) {
t.Errorf("interrupted upload logged as a failure: %q", stderr)
}
}
// An upload that fails for any other reason is still logged as an error.
//
//nolint:paralleltest // captureLog replaces os.Stderr
func TestFailedUploadIsLoggedAsError(t *testing.T) {
inner, err := storage.NewFileStorer(t.TempDir())
if err != nil {
t.Fatal(err)
}
store := faultstore.New(inner)
store.OnPut = func(string) faultstore.PutAction { return faultstore.PutFail }
var uploadErr error
stderr := captureLog(t, func() {
uploadErr = uploadTestBlob(context.Background(), store)
})
if uploadErr == nil {
t.Fatal("expected the upload to fail")
}
if !strings.Contains(stderr, uploadFailedLogMessage) {
t.Errorf("failed upload not logged: %q", stderr)
}
}
+31 -1
View File
@@ -22,6 +22,10 @@ var errBlobHashMismatch = errors.New("blob hash mismatch")
var errBlobNotFullyRead = errors.New(
"blob closed before fully read; hash not verified")
// errWrongPrivateKeyForBlob is returned when the configured key parses
// but is not one of the keys a blob was encrypted to.
var errWrongPrivateKeyForBlob = errors.New("cannot decrypt this blob")
// hashVerifyReader wraps a blobgen.Reader and verifies the double-SHA-256 hash
// of decrypted plaintext when Close is called. It reuses the hash that
// blobgen.Reader already computes internally via its TeeReader, avoiding
@@ -87,10 +91,14 @@ func (v *Vaultik) FetchAndDecryptBlob(
return nil, err
}
reader, err := blobgen.NewReader(rc, identities...)
reader, err := v.newBlobReader(rc, identities)
if err != nil {
_ = rc.Close()
if errors.Is(err, errWrongPrivateKeyForBlob) {
return nil, err
}
return nil, fmt.Errorf("creating blob reader: %w", err)
}
@@ -102,6 +110,28 @@ func (v *Vaultik) FetchAndDecryptBlob(
}, nil
}
// newBlobReader opens the decrypting reader for a blob. When none of the
// identities can decrypt it, the error says in plain words that the
// configured private key is not one of the keys the blob was encrypted
// to and names where the key was configured.
func (v *Vaultik) newBlobReader(
r io.Reader, identities []age.Identity,
) (*blobgen.Reader, error) {
blobReader, err := blobgen.NewReader(r, identities...)
if _, ok := errors.AsType[*age.NoIdentityMatchError](err); ok {
return nil, fmt.Errorf(
"%w: the private key in %s is not one of the keys the blob was encrypted to",
errWrongPrivateKeyForBlob, v.Config.AgeSecretKeySourceName())
}
if err != nil {
return nil, err
}
return blobReader, nil
}
// FetchBlob downloads a blob and returns a reader for the encrypted data.
// The reader is closed as soon as ctx is cancelled, which ends a read
// blocked inside it. A storage error is returned as is: restore and deep
+198
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"database/sql"
"encoding/json"
"io"
"strings"
"sync"
@@ -472,6 +473,171 @@ func TestRemoveSnapshot_NotInLocalIndex(t *testing.T) {
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
}
// TestRemoveSnapshot_RemoteOnlyByRemoteKey checks that a snapshot only
// on the destination store, on a host with no local index, is removed by
// the remote key that 'snapshot list' prints for such a snapshot, and by
// an unambiguous leading part of it — the same identifiers verify and
// restore accept.
func TestRemoveSnapshot_RemoteOnlyByRemoteKey(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
remoteKey := snapshot.RemoteSnapshotKey("snapshot-001")
identifiers := map[string]string{
"full key": remoteKey,
"leading part": remoteKey[:8],
}
for name, identifier := range identifiers {
t.Run(name, func(t *testing.T) {
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// No local index: a host that sees the snapshot only remotely.
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t)
result, err := tv.RemoveSnapshot(identifier,
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.True(t, result.RemoteRemoved)
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
out := tv.Stdout.String()
assert.NotContains(t, out, "from local database")
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
})
}
}
// TestRemoveSnapshot_LocalIndexByRemoteKey checks that a remote key (or
// its unambiguous leading part) that names a snapshot the local index
// holds removes that snapshot's local row and its remote metadata
// together. `snapshot list --json` prints a remote key for every
// snapshot, including local ones.
func TestRemoveSnapshot_LocalIndexByRemoteKey(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
remoteKey := snapshot.RemoteSnapshotKey("snapshot-001")
identifiers := map[string]string{
"full key": remoteKey,
"leading part": remoteKey[:8],
}
for name, identifier := range identifiers {
t.Run(name, func(t *testing.T) {
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// A backup wrote to this destination, so it carries the
// marker, and the local index holds the snapshot.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store)
repos := newLocalIndex(t, "snapshot-001")
tv.Repositories = repos
result, err := tv.RemoveSnapshot(identifier,
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.True(t, result.RemoteRemoved)
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
row, err := repos.Snapshots.GetByID(
context.Background(), "snapshot-001")
require.NoError(t, err)
assert.Nil(t, row)
out := tv.Stdout.String()
assert.Contains(t, out,
"Removed snapshot 'snapshot-001' from local database")
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
})
}
}
// TestRemoveSnapshot_DryRunByLeadingPart checks that a dry run by a
// leading part of a remote key names the snapshot that part resolved to:
// by its ID when the local index holds the snapshot, otherwise by its
// full remote key.
func TestRemoveSnapshot_DryRunByLeadingPart(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
remoteKey := snapshot.RemoteSnapshotKey("snapshot-001")
for name, tc := range map[string]struct {
localIDs []string
shown string
}{
"in local index": {localIDs: []string{"snapshot-001"}, shown: "snapshot-001"},
"remote only": {localIDs: nil, shown: remoteKey},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, tc.localIDs...)
_, err := tv.RemoveSnapshot(remoteKey[:8],
&vaultik.RemoveOptions{DryRun: true})
require.NoError(t, err)
assert.Contains(t, tv.Stdout.String(),
"Would remove snapshot: "+tc.shown+".")
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
})
}
}
// TestRemoveSnapshot_JSONDryRunByLeadingPart checks that a JSON dry run
// by a leading part of a remote key prints the ID and the full remote key
// of the snapshot that part resolved to.
func TestRemoveSnapshot_JSONDryRunByLeadingPart(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
remoteKey := snapshot.RemoteSnapshotKey("snapshot-001")
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001")
_, err := tv.RemoveSnapshot(remoteKey[:8],
&vaultik.RemoveOptions{DryRun: true, JSON: true})
require.NoError(t, err)
var printed map[string]any
require.NoError(t, json.Unmarshal(tv.Stdout.Bytes(), &printed))
assert.Equal(t, "snapshot-001", printed["snapshot_id"])
assert.Equal(t, remoteKey, printed["remote_key"])
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
}
// TestRemoveSnapshot_NotInLocalIndexDeleteFails checks that removing a
// snapshot only the destination store holds fails when its metadata
// cannot be deleted there, under --force and under --json: nothing was
@@ -506,6 +672,38 @@ func TestRemoveSnapshot_NotInLocalIndexDeleteFails(t *testing.T) {
}
}
// TestRemoveSnapshot_LocalIndexByLeadingPartDeleteFails checks the
// warning when a snapshot the local index holds is removed by a leading
// part of its remote key and the delete on the destination store fails.
// The warning names the snapshot by its ID, not by the part given.
func TestRemoveSnapshot_LocalIndexByLeadingPartDeleteFails(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
faulty := faultstore.New(store)
faulty.OnDelete = func(string) bool { return true }
tv := vaultik.NewForTesting(faulty)
tv.Repositories = newLocalIndex(t, "snapshot-001")
result, err := tv.RemoveSnapshot(
snapshot.RemoteSnapshotKey("snapshot-001")[:8],
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.False(t, result.RemoteRemoved)
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
assert.Contains(t, tv.Stdout.String(),
"Run 'vaultik snapshot remove snapshot-001' again")
}
func TestRemoveAllSnapshots_RequiresForce(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
+31 -2
View File
@@ -40,6 +40,9 @@ var (
// which is secret, so the message is safe to print and log.
errInvalidAgeSecretKey = errors.New(
"configured age secret key holds no usable age identity")
// errWrongPrivateKey is returned when the configured key parses but
// is not one of the keys the snapshot was encrypted to.
errWrongPrivateKey = errors.New("cannot decrypt this snapshot")
errBlobMissingFromIndex = errors.New("blob hash missing from blob index")
errChunkNotInAnyBlob = errors.New("chunk not found in any blob")
errBlobIDNotInHashIndex = errors.New("blob id missing from hash index")
@@ -142,6 +145,10 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error {
log.Info("Downloading snapshot metadata...")
tempDB, tempDir, err := v.downloadSnapshotDB(opts.SnapshotID, remoteKey, identities)
if errors.Is(err, errWrongPrivateKey) {
return err
}
if err != nil {
return fmt.Errorf("downloading snapshot database: %w", err)
}
@@ -338,6 +345,28 @@ func (v *Vaultik) restoreIdentities() ([]age.Identity, error) {
return identities, nil
}
// newSnapshotDBReader opens the decrypting reader for a snapshot
// database. When none of the identities can decrypt it, the error says
// in plain words that the configured private key is the wrong one and
// names where it was configured.
func (v *Vaultik) newSnapshotDBReader(
r io.Reader, identities []age.Identity,
) (*blobgen.Reader, error) {
blobReader, err := blobgen.NewReader(r, identities...)
if _, ok := errors.AsType[*age.NoIdentityMatchError](err); ok {
return nil, fmt.Errorf(
"%w: the private key in %s is not one of the keys the backup was encrypted to",
errWrongPrivateKey, v.Config.AgeSecretKeySourceName())
}
if err != nil {
return nil, err
}
return blobReader, nil
}
// restoreAllFiles processes files in blob-locality order: drain every
// file whose blob set is on disk, download the missing blobs for the
// pending file with the smallest uncached count, repeat. This keeps
@@ -825,9 +854,9 @@ func (v *Vaultik) downloadSnapshotDB(
// the plaintext to a temp file. Neither the encrypted bytes nor the
// decrypted database is ever held whole in memory; a snapshot database
// can be large.
blobReader, err := blobgen.NewReader(reader, identities...)
blobReader, err := v.newSnapshotDBReader(reader, identities)
if err != nil {
return nil, "", fmt.Errorf("creating decryption reader: %w", err)
return nil, "", err
}
defer func() { _ = blobReader.Close() }()
+161
View File
@@ -0,0 +1,161 @@
package vaultik_test
import (
"bytes"
"context"
"io"
"path"
"path/filepath"
"testing"
"filippo.io/age"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/blobgen"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestWrongPrivateKeyError checks that restore and deep verify, given a
// valid private key the backup was not encrypted to, fail with one plain
// sentence that names where the key was configured.
func TestWrongPrivateKeyError(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := NewMockStorer()
const snapshotID = "test-host_2026-01-01T00:00:00Z"
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
manifest, err := snapshot.EncodeManifest(
&snapshot.Manifest{SnapshotID: remoteKey}, 3)
require.NoError(t, err)
require.NoError(t, store.Put(ctx,
"metadata/"+remoteKey+"/manifest.json.zst", bytes.NewReader(manifest)))
// The database is encrypted to the test recipient only.
var db bytes.Buffer
writer, err := blobgen.NewWriter(&db, 1, []string{testAgePublicKey})
require.NoError(t, err)
_, err = writer.Write([]byte("snapshot database"))
require.NoError(t, err)
require.NoError(t, writer.Close())
require.NoError(t, store.Put(ctx,
"metadata/"+remoteKey+"/db.zst.age", &db))
otherKey, err := age.GenerateX25519Identity()
require.NoError(t, err)
v := &vaultik.Vaultik{
Config: &config.Config{AgeSecretKey: otherKey.String()},
Storage: store,
Fs: afero.NewMemMapFs(),
Stdout: io.Discard,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
}
v.SetContext(ctx)
// A Config built directly has no recorded source, so the error names
// the config-file field.
const want = "cannot decrypt this snapshot: the private key in " +
"age_secret_key is not one of the keys the backup was encrypted to"
err = v.Restore(&vaultik.RestoreOptions{
SnapshotID: snapshotID,
TargetDir: t.TempDir(),
})
require.EqualError(t, err, want)
err = v.RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true})
require.EqualError(t, err, want)
}
// TestWrongPrivateKeyForBlobError backs up a file, then backs it up again
// from an empty local index with a second recipient added. The second
// snapshot reuses the stored blob, which stays encrypted to the first
// recipient only. With only the added key configured, restore and deep
// verify read the database but fail on the blob with one plain sentence
// that names where the key was configured.
func TestWrongPrivateKeyForBlobError(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
dataPath := filepath.Join(dataDir, "data.bin")
storeDir := t.TempDir()
secondDBPath := filepath.Join(tempDir, "second.sqlite")
chunkSize := int64(64 * 1024)
maxBlobSize := int64(512 * 1024)
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
require.NoError(t, afero.WriteFile(fs, dataPath,
bytesPattern("key-", int(chunkSize)), 0o644))
ctx := context.Background()
runFileStorageBackup(ctx, t, fs, dataDir, storeDir,
filepath.Join(tempDir, "first.sqlite"), chunkSize, maxBlobSize)
env := setupDedupBackupEnv(
ctx, t, fs, storeDir, secondDBPath, chunkSize, maxBlobSize)
defer func() { _ = env.db.Close() }()
addedKey, err := age.GenerateX25519Identity()
require.NoError(t, err)
env.cfg.AgeRecipients = append(env.cfg.AgeRecipients,
addedKey.Recipient().String())
snapshotID, result := runDedupSnapshot(ctx, t, env.sm, env.makeScanner(),
env.cfg.Hostname, "second", dataDir, secondDBPath)
require.Zero(t, result.BlobsUploaded,
"the second backup should reuse the stored blob")
require.NoError(t, env.db.Close())
blobKeys, err := env.storer.List(ctx, "blobs/")
require.NoError(t, err)
require.Len(t, blobKeys, 1)
blobHash := path.Base(blobKeys[0])
env.cfg.AgeSecretKey = addedKey.String()
v := &vaultik.Vaultik{
Config: env.cfg,
Storage: env.storer,
Fs: fs,
Stdout: io.Discard,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
}
v.SetContext(ctx)
const sentence = "cannot decrypt this blob: the private key in " +
"age_secret_key is not one of the keys the blob was encrypted to"
// Restore names the blob by the first 16 characters of its hash.
err = v.Restore(&vaultik.RestoreOptions{
SnapshotID: snapshotID,
TargetDir: t.TempDir(),
})
require.EqualError(t, err, "restoring "+dataPath+": downloading blob "+
blobHash[:16]+": "+sentence+
" (pass --skip-errors to continue past restore failures)")
err = v.RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true})
require.EqualError(t, err,
"blob "+blobHash+" verification failed: "+sentence)
}
+107 -29
View File
@@ -1424,11 +1424,14 @@ type RemoveOptions struct {
LocalOnly bool // Skip remote cleanup; only touch the local index
}
// RemoveResult contains the result of a snapshot removal
// RemoveResult contains the result of a snapshot removal. For a single
// snapshot, SnapshotID is its ID, empty when the ID is not known, and
// RemoteKey is the full remote key of its metadata.
//
//nolint:tagliatelle // snake_case is the established JSON output format
type RemoveResult struct {
SnapshotID string `json:"snapshot_id,omitempty"`
RemoteKey string `json:"remote_key,omitempty"`
SnapshotsRemoved []string `json:"snapshots_removed,omitempty"`
RemoteRemoved bool `json:"remote_removed,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
@@ -1458,25 +1461,35 @@ const snapshotRemoveCommandHint = "vaultik snapshot remove"
func (v *Vaultik) RemoveSnapshot(
snapshotID string, opts *RemoveOptions,
) (*RemoveResult, error) {
result := &RemoveResult{
SnapshotID: snapshotID,
}
result := &RemoveResult{}
err := v.checkStorageBinding()
if err != nil {
return result, err
}
inLocalIndex, err := v.checkSnapshotExists(snapshotID, opts.LocalOnly)
inLocalIndex, resolvedID, remoteKey, err := v.findSnapshotToRemove(
snapshotID, opts.LocalOnly)
if err != nil {
return result, err
}
if opts.DryRun {
return v.removeSnapshotDryRun(result, snapshotID, opts)
result.SnapshotID = resolvedID
result.RemoteKey = remoteKey
// The dry run, the prompt and removeSnapshotRemote name the snapshot
// that snapshotID resolved to: by its ID, or by its full remote key
// when the ID is not known.
shown := resolvedID
if shown == "" {
shown = remoteKey
}
if !v.confirmRemoveSnapshot(snapshotID, opts) {
if opts.DryRun {
return v.removeSnapshotDryRun(result, shown, opts)
}
if !v.confirmRemoveSnapshot(shown, opts) {
return result, ErrNotConfirmed
}
@@ -1489,16 +1502,16 @@ func (v *Vaultik) RemoveSnapshot(
}
if inLocalIndex {
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
log.Info("Removing snapshot from local database", "snapshot_id", resolvedID)
err = v.deleteSnapshotFromLocalDB(snapshotID)
err = v.deleteSnapshotFromLocalDB(resolvedID)
if err != nil {
return result, fmt.Errorf("removing from local database: %w", err)
}
}
result.RemoteRemoved, err = v.removeSnapshotRemote(
snapshotID, inLocalIndex, markerErr, opts)
shown, remoteKey, inLocalIndex, markerErr, opts)
if err != nil {
return result, err
}
@@ -1517,7 +1530,7 @@ func (v *Vaultik) RemoveSnapshot(
if inLocalIndex {
v.UI.Infof("Removed snapshot '%s' from local database.",
v.UI.Snapshot(snapshotID))
v.UI.Snapshot(resolvedID))
}
if result.RemoteRemoved {
@@ -1530,6 +1543,52 @@ func (v *Vaultik) RemoveSnapshot(
return result, nil
}
// findSnapshotToRemove finds the snapshot that identifier names. It
// returns whether the local index holds that snapshot, its snapshot ID,
// and the remote key of its metadata on the destination store. The ID is
// known when identifier is a snapshot ID or the local index holds the
// snapshot; otherwise it is empty.
//
// A snapshot ID is looked up by checkSnapshotExists. Under localOnly the
// store is not consulted, so every identifier is looked up that way and
// a remote key is not found. Otherwise a remote key, or a leading part
// of one, is resolved by resolveSnapshotRemoteKey, as restore and verify
// do. That fails with errSnapshotKeyNotFound or errSnapshotKeyAmbiguous
// when the identifier matches no snapshot on the store, or more than
// one. The store is listed only after checkDestinationMarker passes, as
// in checkSnapshotExists. The local row is the one whose snapshot ID
// hashes to the resolved key, as in RemoveAllSnapshots, so removing by
// remote key removes that row too.
func (v *Vaultik) findSnapshotToRemove(
identifier string, localOnly bool,
) (bool, string, string, error) {
if localOnly || !isRemoteKeyOrPrefix(identifier) {
inLocalIndex, err := v.checkSnapshotExists(identifier, localOnly)
if err != nil {
return false, "", "", err
}
return inLocalIndex, identifier, snapshot.RemoteSnapshotKey(identifier), nil
}
err := v.checkDestinationMarker()
if err != nil {
return false, "", "", err
}
remoteKey, err := v.resolveSnapshotRemoteKey(identifier)
if err != nil {
return false, "", "", err
}
localID, err := v.localSnapshotIDForRemoteKey(remoteKey)
if err != nil {
return false, "", "", err
}
return localID != "", localID, remoteKey, nil
}
// checkSnapshotExists returns errSnapshotNotFound when the local index
// has no row for snapshotID and, unless localOnly is set, the
// destination store has no metadata for it either. inLocalIndex reports
@@ -1639,19 +1698,22 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
}
// removeSnapshotRemote strips the snapshot's metadata from the
// destination store. It returns true only when the delete finished
// without an error and removed at least one object; under LocalOnly it
// does nothing. markerErr is the result of checkDestinationMarker; when
// it is set, nothing is deleted and it is the failure. When the snapshot
// is only on the destination store, a failure is returned as an error,
// because that removal is all the command does. When the snapshot was in
// the local index, its removal there has already happened, so a failure
// is only a warning telling the user to run `vaultik snapshot remove`
// for the snapshot again once the destination store is reachable
// (`vaultik prune` never removes snapshot metadata that has its
// manifest).
// destination store. remoteKey is the resolved metadata directory name
// to delete, as returned by findSnapshotToRemove. shown names the
// snapshot in the log records and the warning: its ID, or its full
// remote key when the ID is not known. It returns true only when the
// delete finished without an error and removed at least one object;
// under LocalOnly it does nothing. markerErr is the result of
// checkDestinationMarker; when it is set, nothing is deleted and it is
// the failure. When the snapshot is only on the destination store, a
// failure is returned as an error, because that removal is all the
// command does. When the snapshot was in the local index, its removal
// there has already happened, so a failure is only a warning telling
// the user to run `vaultik snapshot remove` for the snapshot again once
// the destination store is reachable (`vaultik prune` never removes
// snapshot metadata that has its manifest).
func (v *Vaultik) removeSnapshotRemote(
snapshotID string, inLocalIndex bool, markerErr error,
shown, remoteKey string, inLocalIndex bool, markerErr error,
opts *RemoveOptions,
) (bool, error) {
if opts.LocalOnly {
@@ -1659,14 +1721,13 @@ func (v *Vaultik) removeSnapshotRemote(
}
log.Info("Removing snapshot metadata from remote storage",
"snapshot_id", snapshotID)
"snapshot_id", shown)
var deleted int
err := markerErr
if err == nil {
deleted, err = v.deleteRemoteSnapshotByKey(
snapshot.RemoteSnapshotKey(snapshotID))
deleted, err = v.deleteRemoteSnapshotByKey(remoteKey)
}
if err != nil && !inLocalIndex {
@@ -1678,14 +1739,14 @@ func (v *Vaultik) removeSnapshotRemote(
log.Warn("Could not remove snapshot metadata from remote storage; "+
"run '"+snapshotRemoveCommandHint+"' with the snapshot's ID "+
"again once the remote is reachable",
"snapshot_id", snapshotID, "error", err)
"snapshot_id", shown, "error", err)
// The UI writes to stdout, which under --json holds only the
// document; the log record above is the warning on stderr.
if v.UI != nil && !opts.JSON {
v.UI.Warningf("Could not remove snapshot metadata from remote: "+
"%v. Run '%s %s' again once the remote is reachable.",
err, snapshotRemoveCommandHint, snapshotID)
err, snapshotRemoveCommandHint, shown)
}
return false, nil
@@ -1780,6 +1841,23 @@ func (v *Vaultik) localSnapshotIDs() ([]string, error) {
return ids, nil
}
// localSnapshotIDForRemoteKey returns the human snapshot ID of the local
// index row whose RemoteSnapshotKey is key, or "" when no row has it.
func (v *Vaultik) localSnapshotIDForRemoteKey(key string) (string, error) {
ids, err := v.localSnapshotIDs()
if err != nil {
return "", fmt.Errorf("listing local snapshots: %w", err)
}
for _, id := range ids {
if snapshot.RemoteSnapshotKey(id) == key {
return id, nil
}
}
return "", nil
}
// listAllRemoteSnapshotKeys collects the hashed remote keys
// (subdirectories under metadata/) currently present in the
// destination store. Returns (nil, err) when the store cannot be
+11 -3
View File
@@ -264,6 +264,10 @@ func (v *Vaultik) downloadVerifiedSnapshotDB(
defer func() { _ = dbReader.Close() }()
tdb, err := v.decryptAndLoadDatabase(dbReader, identities)
if errors.Is(err, errWrongPrivateKey) {
return nil, v.deepVerifyFailure(result, opts, err.Error(), err)
}
if err != nil {
return nil, v.deepVerifyFailure(result, opts,
fmt.Sprintf("failed to decrypt database: %v", err),
@@ -348,9 +352,9 @@ func (v *Vaultik) decryptAndLoadDatabase(
reader io.ReadCloser, identities []age.Identity,
) (*tempDB, error) {
// Decrypt and decompress through the shared blobgen reader.
blobReader, err := blobgen.NewReader(reader, identities...)
blobReader, err := v.newSnapshotDBReader(reader, identities)
if err != nil {
return nil, fmt.Errorf("failed to create decryption reader: %w", err)
return nil, err
}
defer func() { _ = blobReader.Close() }()
@@ -423,7 +427,11 @@ func (v *Vaultik) verifyBlob(
// the plaintext as it is read. A blob's hash — its remote name — is the
// double SHA-256 of that plaintext (see blobgen.DoubleSHA256), not of the
// encrypted bytes.
blobReader, err := blobgen.NewReader(reader, identities...)
blobReader, err := v.newBlobReader(reader, identities)
if errors.Is(err, errWrongPrivateKeyForBlob) {
return err
}
if err != nil {
return fmt.Errorf("failed to create blob reader: %w", err)
}
+2 -2
View File
@@ -11,8 +11,8 @@
# when no tag is reachable, each with a "-dirty" suffix when tracked
# files have uncommitted changes. Untracked files are ignored: a stray
# scratch file does not change what was compiled. Outside a git checkout
# (release tarball, `go install`), or in one with no commits, it is
# "dev".
# (such as an unpacked release tarball), or in one with no commits, it
# is "dev".
#
# Nothing here ever invents a version number.
set -eu