Author SHA1 Message Date
sneak 8744e971b2 Label the commit date "commit date" in version and banner (closes #409)
check / check (push) Successful in 32m12s
The value is the commit's date, which the Makefile and the Dockerfile
stamp in, not the time of the build. `vaultik version` labelled it
"build date" and the startup banner "built on". The other version
labels gain one space so every value still starts in one column.

The tests give the commit and the commit date different values, since
the test binary is not stamped and holds "unknown" in both.

Model: opus-5-5
2026-11-10 20:58:06 +00:00
clawbot 5a0da6da12 Write the apps snapshot in config init only on macOS (closes #408)
check / check (push) Failing after 35m29s
The default config backed up /Applications on every system. That directory exists only on macOS, so on Linux the first snapshot create stopped on the missing path. config init now writes the apps snapshot only when runtime.GOOS is darwin; the macOS file is unchanged.

Model: opus-5-5 (implementation, review); opus-5-5 (merge)
2026-11-10 21:52:03 +01:00
clawbot 13a35a986a Correct two false doc comments (closes #403)
check / check (push) Successful in 13m0s
Two doc comments described behaviour that no longer exists. The comment on `ExportSnapshotMetadata` described an SQL dump uploaded under `snapshots/`; it now lists the steps the function performs and the two keys it writes. The comment on `EnsureStorageBinding` named `vaultik database purge`; the command is `vaultik database delete`. Comments only.

Model: opus-5-5
2026-11-09 07:43:24 +01:00
14 changed files with 169 additions and 46 deletions

No files matched your search

+2 -2
View File
@@ -32,8 +32,8 @@ mkdir /Volumes/usbstick/mybackup
# (System Settings → Privacy & Security → Full Disk Access), otherwise # (System Settings → Privacy & Security → Full Disk Access), otherwise
# the backup will abort with a permission error on protected directories # the backup will abort with a permission error on protected directories
# run your first backup (the default config backs up ~ and /Applications # run your first backup (the default config backs up ~, and /Applications
# with sensible excludes) # on macOS only, with sensible excludes)
vaultik snapshot create vaultik snapshot create
# see what you have # see what you have
+5
View File
@@ -24,6 +24,11 @@ owner's; a `v*` tag runs the release build in
# Completed Steps # Completed Steps
- 2026-11-10: Made `config init` write the `apps` snapshot, which backs
up `/Applications`, only on macOS
([issue #408](https://git.eeqj.de/sneak/vaultik/issues/408)). On
Linux the first `snapshot create` stopped on the missing directory.
- 2026-10-15: Stopped `snapshot create --prune` pruning after a backup - 2026-10-15: Stopped `snapshot create --prune` pruning after a backup
in which a volume mounted below a configured path in an earlier in which a volume mounted below a configured path in an earlier
snapshot of the same name was not mounted snapshot of the same name was not mounted
+1 -1
View File
@@ -90,7 +90,7 @@ func setupGlobals(
// blank line. Used both from the fx hook (for subcommand invocations) and // blank line. Used both from the fx hook (for subcommand invocations) and
// from the root cobra Run handler (for `vaultik` with no subcommand). // from the root cobra Run handler (for `vaultik` with no subcommand).
func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) { func writeStartupBanner(w *ui.Writer, startTime time.Time, shortCommit string) {
w.Bannerf("%s %s by %s (commit %s, built on %s) starting up at %s.", w.Bannerf("%s %s by %s (commit %s, commit date %s) starting up at %s.",
globals.Appname, globals.Version, globals.Author, globals.Appname, globals.Version, globals.Author,
shortCommit, globals.CommitDate, shortCommit, globals.CommitDate,
startTime.Format(time.RFC3339)) startTime.Format(time.RFC3339))
+27 -4
View File
@@ -8,6 +8,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect" "reflect"
"runtime"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -30,7 +31,7 @@ const configSetArgs = 2
// parent config dirs (e.g. ~/.config) are conventionally traversable. // parent config dirs (e.g. ~/.config) are conventionally traversable.
const configDirMode = 0o755 const configDirMode = 0o755
// configYAMLIndent matches the 2-space indentation of defaultConfigTemplate, // configYAMLIndent matches the 2-space indentation of the default config,
// so `config set` writes the file back with the same indentation rather than // so `config set` writes the file back with the same indentation rather than
// yaml.Marshal's 4-space default. // yaml.Marshal's 4-space default.
const configYAMLIndent = 2 const configYAMLIndent = 2
@@ -50,7 +51,22 @@ var (
"so the file was not changed") "so the file was not changed")
) )
const defaultConfigTemplate = `# vaultik configuration // macOS is the value of runtime.GOOS on macOS.
const macOS = "darwin"
// defaultConfigText returns the file `config init` writes on the operating
// system goos, named as runtime.GOOS names it. Only macOS gets the apps
// snapshot, since /Applications exists nowhere else.
func defaultConfigText(goos string) string {
if goos == macOS {
return defaultConfigHead + appsSnapshotConfig + defaultConfigTail
}
return defaultConfigHead + defaultConfigTail
}
// defaultConfigHead is the default config up to the end of the home snapshot.
const defaultConfigHead = `# vaultik configuration
# Documentation: https://sneak.berlin/go/vaultik # Documentation: https://sneak.berlin/go/vaultik
# ─── REQUIRED ──────────────────────────────────────────────────────────────── # ─── REQUIRED ────────────────────────────────────────────────────────────────
@@ -168,7 +184,10 @@ snapshots:
- "/Movies/ProxyMedia" - "/Movies/ProxyMedia"
- "/Music/iTunes/Album Artwork" - "/Music/iTunes/Album Artwork"
- "/Pictures/iPod Photo Cache" - "/Pictures/iPod Photo Cache"
`
// appsSnapshotConfig is the apps snapshot of the default config.
const appsSnapshotConfig = `
# Third-party applications. OS-provided apps live in /System/Applications # Third-party applications. OS-provided apps live in /System/Applications
# on modern macOS and are never in /Applications, but Apple-installed # on modern macOS and are never in /Applications, but Apple-installed
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since # App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
@@ -189,7 +208,10 @@ snapshots:
- "/Steam.app" - "/Steam.app"
- "/VirtualBox.app" - "/VirtualBox.app"
- "/Utilities/Adobe Installers" - "/Utilities/Adobe Installers"
`
// defaultConfigTail is the default config after the snapshots.
const defaultConfigTail = `
# Storage backend (pick ONE of the three forms below). # Storage backend (pick ONE of the three forms below).
# #
# S3-compatible: # S3-compatible:
@@ -311,7 +333,8 @@ on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
return fmt.Errorf("creating config directory %s: %w", dir, err) return fmt.Errorf("creating config directory %s: %w", dir, err)
} }
err = os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode) err = os.WriteFile(path, []byte(defaultConfigText(runtime.GOOS)),
configFileMode)
if err != nil { if err != nil {
return fmt.Errorf("writing config file: %w", err) return fmt.Errorf("writing config file: %w", err)
} }
@@ -560,7 +583,7 @@ func yamlPathOnly(root *yaml.Node, keys []string) (*yaml.Node, error) {
} }
// marshalConfigYAML renders a config document tree with 2-space indentation, // marshalConfigYAML renders a config document tree with 2-space indentation,
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which // matching the default config. yaml.Marshal defaults to 4 spaces, which
// would reindent the whole file on the first `config set` despite the promise // would reindent the whole file on the first `config set` despite the promise
// to preserve formatting. // to preserve formatting.
func marshalConfigYAML(root *yaml.Node) ([]byte, error) { func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
+41 -7
View File
@@ -13,14 +13,15 @@ import (
"sneak.berlin/go/vaultik/internal/ui" "sneak.berlin/go/vaultik/internal/ui"
) )
// TestDefaultConfigTemplateParses ensures the init template is valid YAML // TestDefaultConfigParsesOnMacOS ensures the config `config init` writes on
// that unmarshals into the Config struct with the expected snapshots. // macOS is valid YAML that unmarshals into the Config struct with the
func TestDefaultConfigTemplateParses(t *testing.T) { // expected snapshots.
func TestDefaultConfigParsesOnMacOS(t *testing.T) {
t.Parallel() t.Parallel()
var cfg config.Config var cfg config.Config
err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg) err := yaml.Unmarshal([]byte(defaultConfigText("darwin")), &cfg)
if err != nil { if err != nil {
t.Fatalf("default config template is not valid YAML: %v", err) t.Fatalf("default config template is not valid YAML: %v", err)
} }
@@ -58,6 +59,39 @@ func TestDefaultConfigTemplateParses(t *testing.T) {
} }
} }
// TestDefaultConfigOnLinux checks that the config `config init` writes on
// Linux loads with its home snapshot and without the apps snapshot, whose
// /Applications exists only on macOS.
func TestDefaultConfigOnLinux(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(defaultConfigText("linux")), configFileMode)
if err != nil {
t.Fatalf("write config: %v", err)
}
err = writeConfigSet(ui.NewWithColor(&bytes.Buffer{}, false),
path, "storage_url", "file:///mnt/backups")
if err != nil {
t.Fatalf("config set storage_url: %v", err)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("config.Load: %v", err)
}
if _, ok := cfg.Snapshots["home"]; !ok {
t.Error("expected 'home' snapshot in the Linux default config")
}
if _, ok := cfg.Snapshots["apps"]; ok {
t.Error("the Linux default config should have no 'apps' snapshot")
}
}
// testRecipient is a valid age public key. // testRecipient is a valid age public key.
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj" const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
@@ -71,7 +105,7 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yml") path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode) err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode)
if err != nil { if err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
} }
@@ -220,7 +254,7 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
{levelReference, "blob_size_limit", "1MB", {levelReference, "blob_size_limit", "1MB",
"blob_size_limit must be at least"}, "blob_size_limit must be at least"},
// The type check on a config that does not load yet. // The type check on a config that does not load yet.
{defaultConfigTemplate, "snapshots.apps.paths", "/srv", {defaultConfigText(macOS), "snapshots.apps.paths", "/srv",
"into []string"}, "into []string"},
// The type check on a config that cannot be read here. // The type check on a config that cannot be read here.
{configWithUnsetReference, "snapshots.home.paths", "/x", {configWithUnsetReference, "snapshots.home.paths", "/x",
@@ -380,7 +414,7 @@ func loadAfterConfigSet(t *testing.T, key, value string) *config.Config {
path := filepath.Join(t.TempDir(), "config.yml") path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode) err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode)
if err != nil { if err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
} }
+30
View File
@@ -13,6 +13,7 @@ import (
"github.com/adrg/xdg" "github.com/adrg/xdg"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/globals"
) )
// Command words and flags used to build argument vectors below. They are // Command words and flags used to build argument vectors below. They are
@@ -112,6 +113,35 @@ func TestBannerStillPrintedWithoutSuppressingFlag(t *testing.T) {
} }
} }
// TestBannerLabelsCommitDate checks that the banner prints the commit
// and the commit date, each after its own label. Commit and CommitDate
// get different values, so printing the wrong one fails.
//
//nolint:paralleltest // sets globals.Commit and globals.CommitDate
func TestBannerLabelsCommitDate(t *testing.T) {
const (
commit = "abc1234"
commitDate = "2026-11-09"
)
previousCommit, previousCommitDate := globals.Commit, globals.CommitDate
t.Cleanup(func() {
globals.Commit = previousCommit
globals.CommitDate = previousCommitDate
})
globals.Commit = commit
globals.CommitDate = commitDate
var banner bytes.Buffer
emitStartupBanner(nil, &banner)
assert.Contains(t, banner.String(),
"(commit "+commit+", commit date "+commitDate+")")
}
// TestBannerSuppressedInArgs covers the suppression scan directly, // TestBannerSuppressedInArgs covers the suppression scan directly,
// including the flags that suppressed the banner before --json joined // including the flags that suppressed the banner before --json joined
// them, so that adding --json cannot regress them. // them, so that adding --json cannot regress them.
+1 -1
View File
@@ -38,7 +38,7 @@ func writeVersion(out *ui.Writer) {
_, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version) _, _ = fmt.Fprintf(w, "vaultik %s\n", globals.Version)
_, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit) _, _ = fmt.Fprintf(w, " commit: %s\n", globals.Commit)
_, _ = fmt.Fprintf(w, " build date: %s\n", globals.CommitDate) _, _ = fmt.Fprintf(w, " commit date: %s\n", globals.CommitDate)
_, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version()) _, _ = fmt.Fprintf(w, " go: %s\n", runtime.Version())
_, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH) _, _ = fmt.Fprintf(w, " os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
_, _ = fmt.Fprintf(w, " author: %s\n", globals.Author) _, _ = fmt.Fprintf(w, " author: %s\n", globals.Author)
+37
View File
@@ -2,6 +2,7 @@ package cli_test
import ( import (
"bytes" "bytes"
"runtime"
"strings" "strings"
"testing" "testing"
@@ -49,6 +50,42 @@ func TestVersionCommandReportsBuildVersion(t *testing.T) {
} }
} }
// TestVersionCommandReportLines checks the label, padding and value of
// every line of the report. Commit and CommitDate get different values,
// so a line that prints the wrong one fails.
//
//nolint:paralleltest // sets globals.Commit and globals.CommitDate
func TestVersionCommandReportLines(t *testing.T) {
const (
commit = "abc1234"
commitDate = "2026-11-09"
)
previousCommit, previousCommitDate := globals.Commit, globals.CommitDate
t.Cleanup(func() {
globals.Commit = previousCommit
globals.CommitDate = previousCommitDate
})
globals.Commit = commit
globals.CommitDate = commitDate
want := "vaultik " + globals.Version + "\n" +
" commit: " + commit + "\n" +
" commit date: " + commitDate + "\n" +
" go: " + runtime.Version() + "\n" +
" os/arch: " + runtime.GOOS + "/" + runtime.GOARCH + "\n" +
" author: " + globals.Author + "\n" +
" homepage: " + globals.Homepage + "\n" +
" license: " + globals.License + "\n"
out := runVersionCommand(t)
if !strings.HasPrefix(out, want) {
t.Errorf("report:\n%s\nwant it to start with:\n%s", out, want)
}
}
// TestVersionCommandFlagsDevelopmentBuild is the regression test for // TestVersionCommandFlagsDevelopmentBuild is the regression test for
// the thing this command exists to prevent: a build that is not a // the thing this command exists to prevent: a build that is not a
// release must say so. The notice used to be gated on the version // release must say so. The notice used to be gated on the version
+7 -5
View File
@@ -271,16 +271,18 @@ func (sm *SnapshotManager) CompleteSnapshot(
return sm.MarkSnapshotComplete(ctx, snapshotID) return sm.MarkSnapshotComplete(ctx, snapshotID)
} }
// ExportSnapshotMetadata exports snapshot metadata to S3 // ExportSnapshotMetadata exports snapshot metadata to the backup destination store
// //
// This method executes the complete snapshot metadata export process: // This method executes the complete snapshot metadata export process:
// 1. Creates a temporary directory for working files // 1. Creates a temporary directory for working files
// 2. Copies the main database to preserve its state // 2. Copies the main database to preserve its state
// 3. Cleans the copy to contain only current snapshot data // 3. Cleans the copy to contain only current snapshot data
// 4. Dumps the cleaned database to SQL // 4. Vacuums the copy to remove the deleted data
// 5. Compresses the SQL dump with zstd // 5. Compresses the copy with zstd
// 6. Encrypts the compressed data with age (always; recipients are required) // 6. Encrypts the compressed copy with age (always; recipients are required)
// 7. Uploads to S3 at: snapshots/{snapshot-id}.sql.zst[.age] // 7. Builds the blob manifest from the copy, compressed but not encrypted
// 8. Uploads the database to metadata/{remote-key}/db.zst.age
// 9. Uploads the manifest to metadata/{remote-key}/manifest.json.zst
// //
// The only caller (finalizeSnapshotMetadata) does not close the main database // The only caller (finalizeSnapshotMetadata) does not close the main database
// before calling this method: the index is copied at dbPath while it is still // before calling this method: the index is copied at dbPath while it is still
+4 -3
View File
@@ -31,9 +31,10 @@ var (
errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot") errPathsNotInSnapshot = errors.New("requested path(s) not in the snapshot")
errFilesFailedVerify = errors.New("files failed verification") errFilesFailedVerify = errors.New("files failed verification")
errDecryptionKeyRequired = errors.New( errDecryptionKeyRequired = errors.New(
"age secret key is missing; set it in the " + "decryption key required for restore\n\n" +
"VAULTIK_AGE_SECRET_KEY environment variable or the " + "Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
"age_secret_key config file field") "age private key file:\n" +
" export VAULTIK_AGE_SECRET_KEY=\"$(cat vaultik_backup_private_key.txt)\"")
// errInvalidAgeSecretKey is returned when the configured key does not // errInvalidAgeSecretKey is returned when the configured key does not
// parse as any age identity. It names the source but never the value, // parse as any age identity. It names the source but never the value,
// which is secret, so the message is safe to print and log. // which is secret, so the message is safe to print and log.
-20
View File
@@ -106,23 +106,3 @@ func TestRestoreIdentitiesMissingKey(t *testing.T) {
_, err := v.restoreIdentities() _, err := v.restoreIdentities()
require.ErrorIs(t, err, errDecryptionKeyRequired) require.ErrorIs(t, err, errDecryptionKeyRequired)
} }
// TestMissingKeyErrorsNameBothSources asserts restore and deep verify
// return the same missing-key error, whose text names both places the key
// can be set: the VAULTIK_AGE_SECRET_KEY environment variable first since
// it is preferred, then the age_secret_key config file field.
func TestMissingKeyErrorsNameBothSources(t *testing.T) {
t.Parallel()
const want = "age secret key is missing; set it in " +
"the VAULTIK_AGE_SECRET_KEY environment variable or the " +
"age_secret_key config file field"
v := &Vaultik{Config: &config.Config{}}
_, err := v.restoreIdentities()
require.EqualError(t, err, want)
err = v.RunDeepVerify("any-snapshot", &VerifyOptions{})
require.EqualError(t, err, want)
}
+1 -1
View File
@@ -30,7 +30,7 @@ var errStorageBindingMismatch = errors.New(
// nothing and return nil. // nothing and return nil.
// - When the two differ, refuse with an error that tells the user // - When the two differ, refuse with an error that tells the user
// how to recover (revert the config, or run `vaultik database // how to recover (revert the config, or run `vaultik database
// purge` to discard the local index and rebuild against the new // delete` to discard the local index and rebuild against the new
// destination on the next backup). // destination on the next backup).
// //
// Read-only inspection commands (remote info, snapshot list, etc.) // Read-only inspection commands (remote info, snapshot list, etc.)
+5
View File
@@ -162,6 +162,11 @@ func (v *Vaultik) StartOperation(fn func()) func(context.Context) bool {
} }
} }
// CanDecrypt returns true if this Vaultik instance has decryption capabilities
func (v *Vaultik) CanDecrypt() bool {
return v.Config.AgeSecretKey != ""
}
// GetFilesystem returns the filesystem instance used by Vaultik // GetFilesystem returns the filesystem instance used by Vaultik
// //
//nolint:ireturn // afero.Fs is the filesystem abstraction by design //nolint:ireturn // afero.Fs is the filesystem abstraction by design
+8 -2
View File
@@ -23,6 +23,8 @@ import (
// Sentinel errors for snapshot verification failures. // Sentinel errors for snapshot verification failures.
var ( var (
errVerificationFailed = errors.New("verification failed") errVerificationFailed = errors.New("verification failed")
errSecretKeyRequired = errors.New(
"VAULTIK_AGE_SECRET_KEY not set; required for deep verification")
errChunksOutOfOrder = errors.New("chunks out of order") errChunksOutOfOrder = errors.New("chunks out of order")
errChunkHashMismatch = errors.New("chunk hash mismatch") errChunkHashMismatch = errors.New("chunk hash mismatch")
errNegativeChunkLength = errors.New("chunk length is negative") errNegativeChunkLength = errors.New("chunk length is negative")
@@ -104,9 +106,13 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
Mode: "deep", Mode: "deep",
} }
if !v.CanDecrypt() {
return v.deepVerifyFailure(result, opts,
errSecretKeyRequired.Error(), errSecretKeyRequired)
}
// Parse the age secret key once, the same way restore does, and reuse // Parse the age secret key once, the same way restore does, and reuse
// the identities for the database and every blob. A missing key fails // the identities for the database and every blob.
// here, before anything is downloaded.
identities, err := v.restoreIdentities() identities, err := v.restoreIdentities()
if err != nil { if err != nil {
return v.deepVerifyFailure(result, opts, err.Error(), err) return v.deepVerifyFailure(result, opts, err.Error(), err)