6 Commits
Author SHA1 Message Date
sneak c9f3667757 Name the setting and value when a size does not parse (closes #429)
check / check (push) Successful in 12m50s
A size setting such as chunk_size: banana failed with "invalid size
format: strconv.ParseFloat: parsing "": invalid syntax", naming neither
the setting nor the value. The size decoder now quotes the value, and
Parse checks every size setting after ${...} references are filled in,
so the error reads chunk_size: "banana" is not a size; use a number of
bytes or a unit such as 10MB. This covers chunk_size, blob_size_limit
and s3.part_size, and config set. A value too large for a size, such as
100EB or 99999999999999999999, fails as too large, naming the setting.

Model: opus-5-5
2026-11-11 05:13:27 +00: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
29 changed files with 1163 additions and 66 deletions

No files matched your search

+26 -9
View File
@@ -1039,20 +1039,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
}
+2 -1
View File
@@ -276,7 +276,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
+1 -1
View File
@@ -252,7 +252,7 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
{configWithoutRecipients, "snapshots.home.paths", "/srv",
"into []string"},
{configWithoutRecipients, "chunk_size", "fast",
"invalid size format"},
`chunk_size: "fast" is not a size`},
// Values of the right type that fail validation.
{configWithoutRecipients, "compression_level", "99",
"compression_level must be between 1 and 19"},
+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.")
})
}
}
+14 -3
View File
@@ -70,6 +70,9 @@ 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(
@@ -287,7 +290,8 @@ 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, applies default values for optional
// 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
// fields, fills in ${...} references using smartconfig, and expands ~ in
// paths.
func Parse(data []byte) (*Config, error) {
@@ -312,8 +316,9 @@ 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.
err = checkFilledKeys(sc.Data())
// 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())
if err != nil {
return nil, err
}
@@ -387,6 +392,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
@@ -420,6 +427,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
}
+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,
+11 -5
View File
@@ -129,10 +129,11 @@ func checkKeys(node *yaml.Node, typ reflect.Type, path string,
return nil
}
// 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 {
// 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 {
var filled yaml.Node
err := filled.Encode(data)
@@ -140,8 +141,13 @@ func checkFilledKeys(data map[string]any) error {
return fmt.Errorf("failed to marshal config data: %w", err)
}
return checkKeys(&filled, reflect.TypeFor[Config](), "",
err = 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.
+77 -11
View File
@@ -4,13 +4,20 @@ 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.
@@ -32,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
@@ -40,17 +56,66 @@ func (s *Size) UnmarshalYAML(unmarshal func(any) error) error {
return errSizeType
}
// Parse the string using go-humanize
bytes, err := humanize.ParseBytes(strVal)
size, err := ParseSize(strVal)
if err != nil {
return fmt.Errorf("invalid size format: %w", err)
return err
}
if bytes > math.MaxInt64 {
return fmt.Errorf("%w: %s", errSizeTooLarge, strVal)
*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
}
*s = Size(bytes)
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.
}
return nil
}
@@ -70,16 +135,17 @@ func (s Size) String() string {
return humanize.Bytes(uint64(s))
}
// ParseSize parses a size string into a Size value
// 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("invalid size format: %w", err)
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
}
+120
View File
@@ -0,0 +1,120 @@
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)
}
}
+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
+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)
}
+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