From f6df500ba26342d9c71e3f69bd1e10c26c333482 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 11 Nov 2026 06:06:54 +0100 Subject: [PATCH] Refuse a relative index_path (closes #430) 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 --- configs/config.example.yml | 1 + internal/cli/config.go | 3 +- internal/config/config.go | 9 +++++ internal/config/config_test.go | 61 ++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/configs/config.example.yml b/configs/config.example.yml index b23252f..e8fe7b9 100644 --- a/configs/config.example.yml +++ b/configs/config.example.yml @@ -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 diff --git a/internal/cli/config.go b/internal/cli/config.go index 806a78a..ccfd944 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 8b79f88..a615400 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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( @@ -387,6 +390,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 +425,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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8b6f43d..186ea5a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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,