From 60943d8f5e915d6a24fe003fed1c85a2fe4756dd Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 10 Nov 2026 19:59:23 +0000 Subject: [PATCH] Write the apps snapshot in config init only on macOS (closes #408) `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 --- README.md | 4 ++-- TODO.md | 5 ++++ internal/cli/config.go | 31 ++++++++++++++++++++---- internal/cli/config_test.go | 48 +++++++++++++++++++++++++++++++------ 4 files changed, 75 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f83d065..c436e22 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/TODO.md b/TODO.md index c32824f..64f0e47 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/internal/cli/config.go b/internal/cli/config.go index 2a18fc1..96976aa 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -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) { diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 7dccfa0..0bedfbe 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -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(macOS)), &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) }