4 Commits
Author SHA1 Message Date
sneak 39ca0e3111 Make snapshot list report a changed destination instead of advising prune (closes #317)
check / check (push) Canceled after 0s
When the local index is bound to a different destination than the
configured one, snapshot list now prints the warning prune, snapshot
purge and snapshot remove refuse with, including its two ways out
(revert the configuration, or run vaultik database delete). It no
longer reports the local records as missing or advises vaultik prune,
which would refuse.

Reading the binding moves out of EnsureStorageBinding into
storageBinding, which records nothing, so the read-only list can call
it without binding a fresh index.

Judgement call: --json output is unchanged; it gives no advice.

Model: opus-5-5
2026-10-13 22:45:10 +00:00
clawbot 8d5e6f3399 Read the rclone config file named by RCLONE_CONFIG (closes #321)
check / check (push) Canceled after 0s
An rclone destination whose remote is defined only in the file
RCLONE_CONFIG names was reported as not found: rclone reads that
variable only as the value of its --config flag, which vaultik does not
have, so vaultik always used rclone's default config file.
NewRcloneStorer now sets rclone's config path from RCLONE_CONFIG when
the variable is set, as that flag would, including an empty value,
which rclone takes to mean no config file.

Model: opus-5-5
2026-10-14 00:39:32 +02:00
clawbot abad8161bc Make **/ in an exclude pattern match zero directories (closes #338)
check / check (push) Canceled after 0s
The glob library needs a slash on both sides of **, so the anchored
pattern /**/x missed an x directly under the snapshot path, and
a/**/*.log missed a/x.log. As in gitignore, a leading **/ now makes any
pattern, anchored or not, match at every depth, and a pattern holding
/**/ is also compiled with each /**/ made a single slash. The library's
{a,b} alternatives cannot express this: with ** inside them in
mid-pattern they miss paths they should match.

Judgement call: the test for a/**/b uses a/**/*.log, because with a
plain name after the ** the glob library already matched a/b.

Model: opus-5-5
2026-10-14 00:13:27 +02:00
clawbot 05a2c86d65 Make the size defaults and the chunk_size minimum 1000-based (closes #313)
check / check (push) Canceled after 0s
The chunk_size and blob_size_limit defaults and the chunk_size minimum
were 1024-based, while the size parser reads 10MB as 10,000,000 bytes and
the docs and info use 1000-based units. So chunk_size: 1MB was rejected,
writing out a documented default changed it, and info showed an 11 GB
blob limit. They are now 1000-based like the parser. The examples below
the 1MB minimum in config.example.yml and the config init template are
replaced, and both now state the minimum.

Judgement call: s3.part_size keeps its 5MiB to 5GiB bounds, which are
S3's own limits, and MiB and GiB stay listed as accepted spellings.

Model: opus-5-5
2026-10-13 23:56:22 +02:00
12 changed files with 316 additions and 39 deletions
+5 -1
View File
@@ -323,7 +323,11 @@ read from the manifest.
Snapshots in the local index with no counterpart on the destination Snapshots in the local index with no counterpart on the destination
store are reported below the table as drift, with the `vaultik prune` store are reported below the table as drift, with the `vaultik prune`
invocation that reconciles them. invocation that reconciles them. If the local index is bound to a
different destination than the configured one (after a change to
`storage_url`, or `s3.endpoint`, `s3.bucket` or `s3.prefix`), the
command says so instead and gives the same two ways out that `prune`
gives: revert the configuration, or run `vaultik database delete`.
If the destination store cannot be listed (unmounted volume, If the destination store cannot be listed (unmounted volume,
permission denied, network down), the command warns, falls back to the permission denied, network down), the command warns, falls back to the
+2 -1
View File
@@ -302,7 +302,8 @@ storage_url: "rclone://myremote/path/to/backups"
# Average chunk size for content-defined chunking # Average chunk size for content-defined chunking
# Smaller chunks = better deduplication but more metadata # Smaller chunks = better deduplication but more metadata
# Supports: 10MB, 5M, 1GB, 500KB, 64MiB, etc. # Minimum 1MB
# Supports: 10MB, 5M, 1GB, 64MiB, etc.
# Default: 10MB # Default: 10MB
#chunk_size: 10MB #chunk_size: 10MB
+1 -1
View File
@@ -224,7 +224,7 @@ storage_url: ""
# Average chunk size for content-defined chunking (FastCDC). # Average chunk size for content-defined chunking (FastCDC).
# Smaller = better deduplication but more metadata overhead. # Smaller = better deduplication but more metadata overhead.
# Accepts: 1MB, 10M, 64KB, etc. # Minimum 1MB. Accepts: 1MB, 10M, 64MiB, etc.
# Default: 10MB # Default: 10MB
# chunk_size: 10MB # chunk_size: 10MB
+6 -4
View File
@@ -30,13 +30,15 @@ const appName = "vaultik"
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential //nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
const secretKeyPrefix = "AGE-SECRET-KEY-" const secretKeyPrefix = "AGE-SECRET-KEY-"
// Defaults and validation bounds for tunable settings. // Defaults and validation bounds for tunable settings. MB and GB are
// 1000-based, as the size parser reads them; the S3 part sizes are S3's own
// limits, which are 1024-based (MiB, GiB).
const ( const (
defaultBlobSizeLimit = Size(10 * 1024 * 1024 * 1024) // 10GB defaultBlobSizeLimit = Size(10 * 1000 * 1000 * 1000) // 10GB
defaultChunkSize = Size(10 * 1024 * 1024) // 10MB defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
defaultCompressionLevel = 3 defaultCompressionLevel = 3
minChunkSize = 1024 * 1024 // 1MB minChunkSize = 1000 * 1000 // 1MB
minCompressionLevel = 1 minCompressionLevel = 1
maxCompressionLevel = 19 maxCompressionLevel = 19
// S3 accepts a multipart upload part from 5MiB to 5GiB. // S3 accepts a multipart upload part from 5MiB to 5GiB.
+75
View File
@@ -337,6 +337,81 @@ func TestLoadS3PartSize(t *testing.T) {
}) })
} }
// TestDocumentedSizeDefaults checks that the defaults are the documented
// chunk_size 10MB and blob_size_limit 10GB as the parser reads those values
// (1000-based), so writing a default out in the config file changes nothing.
func TestDocumentedSizeDefaults(t *testing.T) {
t.Parallel()
cfg, err := Parse([]byte("chunk_size: 10MB\nblob_size_limit: 10GB\n"))
if err != nil {
t.Fatalf("Parse() unexpected error: %v", err)
}
if cfg.ChunkSize != defaultChunkSize {
t.Errorf("chunk_size: 10MB is %d bytes, the default is %d",
cfg.ChunkSize, defaultChunkSize)
}
if cfg.BlobSizeLimit != defaultBlobSizeLimit {
t.Errorf("blob_size_limit: 10GB is %d bytes, the default is %d",
cfg.BlobSizeLimit, defaultBlobSizeLimit)
}
}
// TestDocumentedSizeExamples checks the 1MB minimum chunk_size and that
// every size the README, config.example.yml and the config init template
// list as an example is accepted.
func TestDocumentedSizeExamples(t *testing.T) {
t.Parallel()
const withoutSizes = "snapshots:\n" +
" test:\n" +
" paths: [/tmp/vaultik-test-source]\n" +
"storage_url: file:///tmp/vaultik-test-storage\n"
tests := []struct {
setting string
wantErr error
}{
{"chunk_size: 1MB", nil},
{"chunk_size: 999999", errChunkSizeTooSmall},
{"chunk_size: 5M", nil},
{"chunk_size: 10M", nil},
{"chunk_size: 10MB", nil},
{"chunk_size: 64MiB", nil},
{"chunk_size: 1GB", nil},
// Four times the default chunk_size, the documented minimum.
{"blob_size_limit: 40MB", nil},
{"blob_size_limit: 500MB", nil},
{"blob_size_limit: 1GB", nil},
{"blob_size_limit: 1GiB", nil},
{"blob_size_limit: 10G", nil},
{"blob_size_limit: 10GB", nil},
{"s3: {part_size: 5MiB}", nil},
{"s3: {part_size: 10MB}", nil},
{"s3: {part_size: 16MiB}", nil},
{"s3: {part_size: 100MiB}", nil},
{"s3: {part_size: 5GiB}", nil},
}
for _, tt := range tests {
t.Run(tt.setting, func(t *testing.T) {
t.Parallel()
cfg, err := Parse([]byte(withoutSizes + tt.setting + "\n"))
if err != nil {
t.Fatalf("Parse() unexpected error: %v", err)
}
err = cfg.Validate()
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
}
})
}
}
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a // TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
// relative path fails at load. Accepted, it would resolve against the // relative path fails at load. Accepted, it would resolve against the
// working directory, so a backup run from another directory would write // working directory, so a backup run from another directory would write
+51
View File
@@ -578,3 +578,54 @@ func TestExcludePatterns_DoubleStarFileAtEveryDepth(t *testing.T) {
require.Equal(t, 1, result.FilesScanned, require.Equal(t, 1, result.FilesScanned,
"**/ file pattern should match at the top level and nested") "**/ file pattern should match at the top level and nested")
} }
func TestExcludePatterns_AnchoredDoubleStarAtEveryDepth(t *testing.T) {
t.Parallel()
// As in gitignore, /**/ also matches directly under the source dir
fs := setupAnchoredTestFS(t)
scanner, repos, cleanup := createTestScanner(t, fs, []string{"/**/projectname"})
defer cleanup()
ctx := context.Background()
createSnapshotRecord(ctx, t, repos, "test-snapshot")
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
require.NoError(t, err)
// /**/projectname should exclude BOTH:
// - /backup/projectname/file.txt
// - /backup/otherproject/projectname/file.txt
// Total files: 4, excluded: 2
require.Equal(t, 2, result.FilesScanned,
"/**/ pattern should match at the top level and nested")
}
func TestExcludePatterns_DoubleStarInMiddleMatchesZeroDirectories(t *testing.T) {
t.Parallel()
// As in gitignore, a/**/b matches a/b as well as a/c/b. The last part
// is a glob: with a plain name the glob library already matched a/b.
fs := &afero.MemMapFs{}
files := []string{"/backup/a/x.log", "/backup/a/c/y.log", "/backup/a/c/kept.txt"}
for _, path := range files {
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755))
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0644))
}
scanner, repos, cleanup := createTestScanner(t, fs, []string{"a/**/*.log"})
defer cleanup()
ctx := context.Background()
createSnapshotRecord(ctx, t, repos, "test-snapshot")
result, err := scanner.Scan(ctx, "/backup", "test-snapshot")
require.NoError(t, err)
// a/**/*.log should exclude /backup/a/x.log and /backup/a/c/y.log
// Total files: 3, excluded: 2
require.Equal(t, 1, result.FilesScanned,
"a/**/*.log should match a/x.log as well as a/c/y.log")
}
+45 -18
View File
@@ -2035,33 +2035,60 @@ func compileExcludePatterns(patterns []string) []compiledPattern {
pattern = strings.TrimSuffix(pattern, "/") pattern = strings.TrimSuffix(pattern, "/")
// A leading "**/" demands a "/" before the name and so misses an // A leading "**/" demands a "/" before the name and so misses an
// entry directly under the source path. An unanchored pattern // entry directly under the source path. It means "at every depth",
// already matches at every depth, so drop the prefix: as in // which is what an unanchored pattern already does, so drop the
// gitignore, "**/x" then also matches a top-level "x". // prefix and match unanchored: as in gitignore, "**/x" and
if !anchored { // "/**/x" then also match a top-level "x".
pattern = strings.TrimPrefix(pattern, "**/") if rest, found := strings.CutPrefix(pattern, "**/"); found {
pattern = rest
anchored = false
} }
// Compile the glob pattern // Likewise "/**/" inside a pattern demands two slashes, so
// For patterns without path separators, we need to match them as components // "a/**/*.log" misses "a/x.log". As in gitignore it stands for
// e.g., ".git" should match ".git" anywhere in the path // zero or more directories, so also compile the pattern with it
g, err := glob.Compile(pattern, '/') // made a single "/". The glob library's {a,b} is no help: with a
if err != nil { // "**" inside it in mid-pattern, it misses paths it should match.
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err) for _, variant := range doubleStarVariants(pattern) {
// For patterns without path separators, we need to match them as components
// e.g., ".git" should match ".git" anywhere in the path
g, err := glob.Compile(variant, '/')
if err != nil {
log.Warn("Invalid exclude pattern, skipping", "pattern", p, "error", err)
continue break
}
compiled = append(compiled, compiledPattern{
pattern: g,
anchored: anchored,
original: p,
})
} }
compiled = append(compiled, compiledPattern{
pattern: g,
anchored: anchored,
original: p,
})
} }
return compiled return compiled
} }
// doubleStarVariants returns pattern once for every way of keeping each
// "/**/" in it or making it a single "/".
func doubleStarVariants(pattern string) []string {
const doubleStar = "/**/"
before, after, found := strings.Cut(pattern, doubleStar)
if !found {
return []string{pattern}
}
var variants []string
for _, rest := range doubleStarVariants(after) {
variants = append(variants, before+doubleStar+rest, before+"/"+rest)
}
return variants
}
// shouldExclude checks if a path should be excluded based on exclude patterns // shouldExclude checks if a path should be excluded based on exclude patterns
// filePath is the full path to the file // filePath is the full path to the file
// rootPath is the root of the backup source directory // rootPath is the root of the backup source directory
+11
View File
@@ -7,10 +7,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os"
"strings" "strings"
"time" "time"
"github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configfile" "github.com/rclone/rclone/fs/config/configfile"
"github.com/rclone/rclone/fs/operations" "github.com/rclone/rclone/fs/operations"
@@ -33,6 +35,15 @@ type RcloneStorer struct {
// The remote parameter is the rclone remote name (as configured via `rclone config`). // The remote parameter is the rclone remote name (as configured via `rclone config`).
// The path parameter is the path within the remote. // The path parameter is the path within the remote.
func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) { func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) {
// rclone reads RCLONE_CONFIG only as the value of its --config flag,
// which vaultik does not have, so it is applied here as that flag would.
if configPath, ok := os.LookupEnv("RCLONE_CONFIG"); ok {
err := config.SetConfigPath(configPath)
if err != nil {
return nil, fmt.Errorf("using RCLONE_CONFIG %q: %w", configPath, err)
}
}
// Install the default config file handler // Install the default config file handler
configfile.Install() configfile.Install()
+39
View File
@@ -11,6 +11,7 @@ import (
"testing" "testing"
"github.com/rclone/rclone/fs" "github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configmap" "github.com/rclone/rclone/fs/config/configmap"
"sneak.berlin/go/vaultik/internal/storage" "sneak.berlin/go/vaultik/internal/storage"
) )
@@ -390,3 +391,41 @@ func TestNewRcloneStorerUnknownRemote(t *testing.T) {
t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err) t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err)
} }
} }
// TestNewRcloneStorerReadsRcloneConfigEnv checks that a remote defined only
// in the file RCLONE_CONFIG names is found and written to, as rclone itself
// would.
func TestNewRcloneStorerReadsRcloneConfigEnv(t *testing.T) {
// rclone keeps the config file path in a process-global variable, which
// NewRcloneStorer leaves pointing at this test's file.
previous := config.GetConfigPath()
t.Cleanup(func() { _ = config.SetConfigPath(previous) })
confFile := filepath.Join(t.TempDir(), "rclone.conf")
err := os.WriteFile(confFile, []byte("[loc]\ntype = local\n"), 0o600)
if err != nil {
t.Fatalf("writing rclone config: %v", err)
}
t.Setenv("RCLONE_CONFIG", confFile)
dir := t.TempDir()
ctx := context.Background()
s, err := storage.NewRcloneStorer(ctx, "loc", dir)
if err != nil {
t.Fatalf("NewRcloneStorer: %v", err)
}
err = s.Put(ctx, testBlobKey, strings.NewReader("blob-bytes"))
if err != nil {
t.Fatalf("Put: %v", err)
}
_, err = os.Stat(filepath.Join(dir, testBlobKey))
if err != nil {
t.Errorf("object is not in the remote's directory: %v", err)
}
}
+23 -4
View File
@@ -61,7 +61,9 @@ const tabPadding = 3
// attempt is made to recover or fabricate the human ID. // attempt is made to recover or fabricate the human ID.
// //
// Snapshots in the local index with no counterpart on the destination // Snapshots in the local index with no counterpart on the destination
// store are reported as drift below the table. // store are reported as drift below the table, unless the local index
// is bound to a different destination than the configured one: that is
// reported instead.
// //
// If remote listing fails (unmounted volume, permission denied, // If remote listing fails (unmounted volume, permission denied,
// network), we degrade to local-only with a warning. List never fails // network), we degrade to local-only with a warning. List never fails
@@ -125,8 +127,22 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
} }
} }
stored, configured, err := v.storageBinding()
if err != nil {
return err
}
// A local index bound to another destination describes that one, so
// its records are expected to be missing here, and prune refuses to
// run. The binding is reported instead of advising prune.
boundElsewhere := stored != "" && stored != configured
if boundElsewhere {
v.UI.Warningf("%v\n%s", errStorageBindingMismatch,
buildBindingMismatchMessage(stored, configured))
}
if remoteErr == nil { if remoteErr == nil {
v.reportListDrift(snapshots, listing) v.reportListDrift(snapshots, listing, boundElsewhere)
} }
return nil return nil
@@ -432,7 +448,9 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
// reportListDrift prints the reconciliation notes the merged table // reportListDrift prints the reconciliation notes the merged table
// cannot express on its own: local records with no counterpart on the // cannot express on its own: local records with no counterpart on the
// destination store, plus counts of remote snapshots that were // destination store, plus counts of remote snapshots that were
// unreadable or omitted. // unreadable or omitted. Local records are not reported when
// boundElsewhere is set: ListSnapshots has already said why they are
// missing.
// //
// This is what remains of the old reportRemoteDrift, and it no longer // This is what remains of the old reportRemoteDrift, and it no longer
// touches the destination store. Its remote-only half collapsed into // touches the destination store. Its remote-only half collapsed into
@@ -441,6 +459,7 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
// so `snapshot list` lists the destination exactly once per invocation. // so `snapshot list` lists the destination exactly once per invocation.
func (v *Vaultik) reportListDrift( func (v *Vaultik) reportListDrift(
snapshots []SnapshotInfo, listing *remoteSnapshotListing, snapshots []SnapshotInfo, listing *remoteSnapshotListing,
boundElsewhere bool,
) { ) {
var localOnly []string var localOnly []string
@@ -450,7 +469,7 @@ func (v *Vaultik) reportListDrift(
} }
} }
if len(localOnly) > 0 { if len(localOnly) > 0 && !boundElsewhere {
v.UI.Warningf("%d local snapshot record(s) not found in backup "+ v.UI.Warningf("%d local snapshot record(s) not found in backup "+
"destination store:", len(localOnly)) "destination store:", len(localOnly))
+35
View File
@@ -421,6 +421,41 @@ func TestListSnapshots_LocalOnlyReportedAsDrift(t *testing.T) {
assert.NotContains(t, out, "snapshot cleanup") assert.NotContains(t, out, "snapshot cleanup")
} }
// TestListSnapshots_BoundElsewhereDoesNotAdvisePrune covers a changed
// storage_url: the local index is still bound to the old destination,
// so its records are missing from the new one. prune refuses to run in
// that state, so the listing must give the choices prune gives instead
// of advising it.
func TestListSnapshots_BoundElsewhereDoesNotAdvisePrune(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
const (
oldURL = "file:///mnt/backups/old"
newURL = "file:///mnt/backups/new"
)
env := newListEnv(t)
env.v.Config.StorageURL = newURL
require.NoError(t, env.v.Repositories.LocalMeta.Set(context.Background(),
database.LocalMetaKeyStorageURL, oldURL))
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
err := env.v.ListSnapshots(false)
require.NoError(t, err)
out := env.stdout.String()
assert.Contains(t, out, listLocalID)
assert.Contains(t, out,
"local index is bound to a different backup destination")
assert.Contains(t, out, oldURL)
assert.Contains(t, out, newURL)
assert.Contains(t, out, "revert storage_url")
assert.Contains(t, out, "vaultik database delete")
assert.NotContains(t, out, "vaultik prune")
}
// TestListSnapshots_UnreachableRemoteDegrades covers the promise in the // TestListSnapshots_UnreachableRemoteDegrades covers the promise in the
// doc comment: an unreachable destination is a warning plus local-only // doc comment: an unreachable destination is a warning plus local-only
// output, never a failure. // output, never a failure.
+23 -10
View File
@@ -45,17 +45,9 @@ func (v *Vaultik) EnsureStorageBinding() error {
return nil return nil
} }
configured := v.Config.StorageURL stored, configured, err := v.storageBinding()
if configured == "" {
// The s3.* form: bind on its endpoint, bucket and prefix,
// written the way storage_url would name the same destination.
s3 := v.Config.S3
configured = "s3://" + s3.Bucket + "/" + s3.Prefix + "?endpoint=" + s3.Endpoint
}
stored, err := v.Repositories.LocalMeta.Get(v.ctx, database.LocalMetaKeyStorageURL)
if err != nil { if err != nil {
return fmt.Errorf("reading local storage binding: %w", err) return err
} }
if stored == "" { if stored == "" {
@@ -78,6 +70,27 @@ func (v *Vaultik) EnsureStorageBinding() error {
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured)) errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
} }
// storageBinding returns the destination the local index is bound to
// ("" when no binding is recorded yet), then the configured destination
// written the same way. It records nothing, so a read-only command can
// call it.
func (v *Vaultik) storageBinding() (string, string, error) {
configured := v.Config.StorageURL
if configured == "" {
// The s3.* form: bind on its endpoint, bucket and prefix,
// written the way storage_url would name the same destination.
s3 := v.Config.S3
configured = "s3://" + s3.Bucket + "/" + s3.Prefix + "?endpoint=" + s3.Endpoint
}
stored, err := v.Repositories.LocalMeta.Get(v.ctx, database.LocalMetaKeyStorageURL)
if err != nil {
return "", "", fmt.Errorf("reading local storage binding: %w", err)
}
return stored, configured, nil
}
// buildBindingMismatchMessage assembles the multi-line explanation // buildBindingMismatchMessage assembles the multi-line explanation
// shown when the local index is bound to a different destination than // shown when the local index is bound to a different destination than
// the currently-configured one (the first line lives in the // the currently-configured one (the first line lives in the