Author SHA1 Message Date
sneak f06b49459d Write the apps snapshot in config init only on macOS (closes #408)
check / check (push) Failing after 22m20s
`config init` wrote an `apps` snapshot of `/Applications` on every
system. That directory exists only on macOS, so on Linux the first
`snapshot create` stopped on it unless `--skip-errors` was given.

`defaultConfigText` takes the operating system name and returns the
file with the `apps` snapshot and its comment only for `darwin`;
`config init` passes `runtime.GOOS`. The macOS file is unchanged byte
for byte.

Model: opus-5-5
2026-11-10 20:23:53 +00: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
10 changed files with 100 additions and 44 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
# the backup will abort with a permission error on protected directories
# run your first backup (the default config backs up ~ and /Applications
# with sensible excludes)
# run your first backup (the default config backs up ~, and /Applications
# on macOS only, with sensible excludes)
vaultik snapshot create
# see what you have
+5
View File
@@ -24,6 +24,11 @@ owner's; a `v*` tag runs the release build in
# 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
in which a volume mounted below a configured path in an earlier
snapshot of the same name was not mounted
+27 -4
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"reflect"
"runtime"
"slices"
"strconv"
"strings"
@@ -30,7 +31,7 @@ const configSetArgs = 2
// parent config dirs (e.g. ~/.config) are conventionally traversable.
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
// yaml.Marshal's 4-space default.
const configYAMLIndent = 2
@@ -50,7 +51,22 @@ var (
"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
# ─── REQUIRED ────────────────────────────────────────────────────────────────
@@ -168,7 +184,10 @@ snapshots:
- "/Movies/ProxyMedia"
- "/Music/iTunes/Album Artwork"
- "/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
# on modern macOS and are never in /Applications, but Apple-installed
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
@@ -189,7 +208,10 @@ snapshots:
- "/Steam.app"
- "/VirtualBox.app"
- "/Utilities/Adobe Installers"
`
// defaultConfigTail is the default config after the snapshots.
const defaultConfigTail = `
# Storage backend (pick ONE of the three forms below).
#
# 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)
}
err = os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
err = os.WriteFile(path, []byte(defaultConfigText(runtime.GOOS)),
configFileMode)
if err != nil {
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,
// 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
// to preserve formatting.
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
+41 -7
View File
@@ -13,14 +13,15 @@ import (
"sneak.berlin/go/vaultik/internal/ui"
)
// TestDefaultConfigTemplateParses ensures the init template is valid YAML
// that unmarshals into the Config struct with the expected snapshots.
func TestDefaultConfigTemplateParses(t *testing.T) {
// TestDefaultConfigParsesOnMacOS ensures the config `config init` writes on
// macOS is valid YAML that unmarshals into the Config struct with the
// expected snapshots.
func TestDefaultConfigParsesOnMacOS(t *testing.T) {
t.Parallel()
var cfg config.Config
err := yaml.Unmarshal([]byte(defaultConfigTemplate), &cfg)
err := yaml.Unmarshal([]byte(defaultConfigText("darwin")), &cfg)
if err != nil {
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.
const testRecipient = "age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj"
@@ -71,7 +105,7 @@ func TestConfigSetRecipientOnFreshConfig(t *testing.T) {
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 {
t.Fatalf("write config: %v", err)
}
@@ -220,7 +254,7 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
{levelReference, "blob_size_limit", "1MB",
"blob_size_limit must be at least"},
// The type check on a config that does not load yet.
{defaultConfigTemplate, "snapshots.apps.paths", "/srv",
{defaultConfigText(macOS), "snapshots.apps.paths", "/srv",
"into []string"},
// The type check on a config that cannot be read here.
{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")
err := os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
err := os.WriteFile(path, []byte(defaultConfigText(macOS)), configFileMode)
if err != nil {
t.Fatalf("write config: %v", err)
}
+7 -5
View File
@@ -271,16 +271,18 @@ func (sm *SnapshotManager) CompleteSnapshot(
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:
// 1. Creates a temporary directory for working files
// 2. Copies the main database to preserve its state
// 3. Cleans the copy to contain only current snapshot data
// 4. Dumps the cleaned database to SQL
// 5. Compresses the SQL dump with zstd
// 6. Encrypts the compressed data with age (always; recipients are required)
// 7. Uploads to S3 at: snapshots/{snapshot-id}.sql.zst[.age]
// 4. Vacuums the copy to remove the deleted data
// 5. Compresses the copy with zstd
// 6. Encrypts the compressed copy with age (always; recipients are required)
// 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
// 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")
errFilesFailedVerify = errors.New("files failed verification")
errDecryptionKeyRequired = errors.New(
"age secret key is missing; set it in the " +
"VAULTIK_AGE_SECRET_KEY environment variable or the " +
"age_secret_key config file field")
"decryption key required for restore\n\n" +
"Set the VAULTIK_AGE_SECRET_KEY environment variable to your " +
"age private key file:\n" +
" export VAULTIK_AGE_SECRET_KEY=\"$(cat vaultik_backup_private_key.txt)\"")
// errInvalidAgeSecretKey is returned when the configured key does not
// 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.
-20
View File
@@ -106,23 +106,3 @@ func TestRestoreIdentitiesMissingKey(t *testing.T) {
_, err := v.restoreIdentities()
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.
// - When the two differ, refuse with an error that tells the user
// 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).
//
// 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
//
//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.
var (
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")
errChunkHashMismatch = errors.New("chunk hash mismatch")
errNegativeChunkLength = errors.New("chunk length is negative")
@@ -104,9 +106,13 @@ func (v *Vaultik) RunDeepVerify(snapshotID string, opts *VerifyOptions) error {
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
// the identities for the database and every blob. A missing key fails
// here, before anything is downloaded.
// the identities for the database and every blob.
identities, err := v.restoreIdentities()
if err != nil {
return v.deepVerifyFailure(result, opts, err.Error(), err)