diff --git a/go.mod b/go.mod index cf79459..e6eb5ae 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,6 @@ require ( github.com/google/uuid v1.6.0 github.com/johannesboyne/gofakes3 v0.0.0-20250603205740-ed9094be7668 github.com/klauspost/compress v1.18.1 - github.com/mattn/go-sqlite3 v1.14.29 github.com/rclone/rclone v1.72.1 github.com/schollz/progressbar/v3 v3.19.0 github.com/spf13/afero v1.15.0 diff --git a/go.sum b/go.sum index 9e91366..fbe492f 100644 --- a/go.sum +++ b/go.sum @@ -593,8 +593,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ= -github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= diff --git a/internal/cli/entry_test.go b/internal/cli/entry_test.go index c3aebb0..86f92ae 100644 --- a/internal/cli/entry_test.go +++ b/internal/cli/entry_test.go @@ -18,7 +18,7 @@ func TestCLIEntry(t *testing.T) { } // Verify all subcommands are registered - expectedCommands := []string{"snapshot", "store", "restore", "prune", "verify", "info", "version"} + expectedCommands := []string{"snapshot", "store", "restore", "prune", "info", "version", "remote", "database"} for _, expected := range expectedCommands { found := false for _, cmd := range cmd.Commands() { diff --git a/internal/cli/purge.go b/internal/cli/purge.go deleted file mode 100644 index 749ac1e..0000000 --- a/internal/cli/purge.go +++ /dev/null @@ -1,100 +0,0 @@ -package cli - -import ( - "context" - "fmt" - "os" - - "git.eeqj.de/sneak/vaultik/internal/log" - "git.eeqj.de/sneak/vaultik/internal/vaultik" - "github.com/spf13/cobra" - "go.uber.org/fx" -) - -// PurgeOptions contains options for the purge command -type PurgeOptions struct { - KeepLatest bool - OlderThan string - Force bool -} - -// NewPurgeCommand creates the purge command -func NewPurgeCommand() *cobra.Command { - opts := &PurgeOptions{} - - cmd := &cobra.Command{ - Use: "purge", - Short: "Purge old snapshots", - Long: `Removes snapshots based on age or count criteria. - -This command allows you to: -- Keep only the latest snapshot (--keep-latest) -- Remove snapshots older than a specific duration (--older-than) - -Config is located at /etc/vaultik/config.yml by default, but can be overridden by -specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - // Validate flags - if !opts.KeepLatest && opts.OlderThan == "" { - return fmt.Errorf("must specify either --keep-latest or --older-than") - } - if opts.KeepLatest && opts.OlderThan != "" { - return fmt.Errorf("cannot specify both --keep-latest and --older-than") - } - - // Use unified config resolution - configPath, err := ResolveConfigPath() - if err != nil { - return err - } - - // Use the app framework like other commands - rootFlags := GetRootFlags() - return RunWithApp(cmd.Context(), AppOptions{ - ConfigPath: configPath, - LogOptions: log.LogOptions{ - Verbose: rootFlags.Verbose, - Debug: rootFlags.Debug, - Quiet: rootFlags.Quiet, - }, - Modules: []fx.Option{}, - Invokes: []fx.Option{ - fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - // Start the purge operation in a goroutine - go func() { - // Run the purge operation - if err := v.PurgeSnapshots(opts.KeepLatest, opts.OlderThan, opts.Force); err != nil { - if err != context.Canceled { - log.Error("Purge operation failed", "error", err) - os.Exit(1) - } - } - - // Shutdown the app when purge completes - if err := v.Shutdowner.Shutdown(); err != nil { - log.Error("Failed to shutdown", "error", err) - } - }() - return nil - }, - OnStop: func(ctx context.Context) error { - log.Debug("Stopping purge operation") - v.Cancel() - return nil - }, - }) - }), - }, - }) - }, - } - - cmd.Flags().BoolVar(&opts.KeepLatest, "keep-latest", false, "Keep only the latest snapshot") - cmd.Flags().StringVar(&opts.OlderThan, "older-than", "", "Remove snapshots older than duration (e.g. 30d, 6m, 1y)") - cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompts") - - return cmd -} diff --git a/internal/cli/restore.go b/internal/cli/restore.go index c69bf6e..62a1363 100644 --- a/internal/cli/restore.go +++ b/internal/cli/restore.go @@ -2,6 +2,7 @@ package cli import ( "context" + "os" "git.eeqj.de/sneak/vaultik/internal/config" "git.eeqj.de/sneak/vaultik/internal/globals" @@ -108,6 +109,7 @@ Examples: if err := app.Vaultik.Restore(restoreOpts); err != nil { if err != context.Canceled { log.Error("Restore operation failed", "error", err) + os.Exit(1) } } diff --git a/internal/cli/root.go b/internal/cli/root.go index f17d780..f03a30d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -41,7 +41,6 @@ on the source system.`, cmd.AddCommand( NewRestoreCommand(), NewPruneCommand(), - NewVerifyCommand(), NewStoreCommand(), NewSnapshotCommand(), NewInfoCommand(), diff --git a/internal/cli/snapshot.go b/internal/cli/snapshot.go index 4699e71..6a801b8 100644 --- a/internal/cli/snapshot.go +++ b/internal/cli/snapshot.go @@ -75,6 +75,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, if err := v.CreateSnapshot(opts); err != nil { if err != context.Canceled { log.Error("Snapshot creation failed", "error", err) + os.Exit(1) } } @@ -99,7 +100,7 @@ specifying a path using --config or by setting VAULTIK_CONFIG to a path.`, } cmd.Flags().BoolVar(&opts.Cron, "cron", false, "Run in cron mode (silent unless error)") - cmd.Flags().BoolVar(&opts.Prune, "prune", false, "Delete all previous snapshots and unreferenced blobs after backup") + cmd.Flags().BoolVar(&opts.Prune, "prune", false, "After backup, drop older snapshots of the same name and remove orphaned blobs") cmd.Flags().BoolVar(&opts.SkipErrors, "skip-errors", false, "Skip file read errors (log them loudly but continue)") return cmd @@ -169,12 +170,17 @@ func newSnapshotPurgeCommand() *cobra.Command { var keepLatest bool var olderThan string var force bool + var names []string cmd := &cobra.Command{ Use: "purge", Short: "Purge old snapshots", - Long: "Removes snapshots based on age or count criteria", - Args: cobra.NoArgs, + Long: `Removes snapshots based on age or count criteria. + +Retention is per-snapshot-name: --keep-latest keeps the latest of each +configured snapshot name, not the latest globally. Use --snapshot to +restrict the operation to specific snapshot names.`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { // Validate flags if !keepLatest && olderThan == "" { @@ -204,7 +210,13 @@ func newSnapshotPurgeCommand() *cobra.Command { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go func() { - if err := v.PurgeSnapshots(keepLatest, olderThan, force); err != nil { + purgeOpts := &vaultik.PurgeOptions{ + KeepLatest: keepLatest, + OlderThan: olderThan, + Force: force, + Names: names, + } + if err := v.PurgeSnapshots(purgeOpts); err != nil { if err != context.Canceled { log.Error("Failed to purge snapshots", "error", err) os.Exit(1) @@ -227,9 +239,10 @@ func newSnapshotPurgeCommand() *cobra.Command { }, } - cmd.Flags().BoolVar(&keepLatest, "keep-latest", false, "Keep only the latest snapshot") + cmd.Flags().BoolVar(&keepLatest, "keep-latest", false, "Keep only the latest snapshot of each name") cmd.Flags().StringVar(&olderThan, "older-than", "", "Remove snapshots older than duration (e.g., 30d, 6m, 1y)") cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + cmd.Flags().StringArrayVar(&names, "snapshot", nil, "Restrict to snapshots with these names (repeat for multiple)") return cmd } @@ -275,13 +288,7 @@ func newSnapshotVerifyCommand() *cobra.Command { lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { go func() { - var err error - if opts.Deep { - err = v.RunDeepVerify(snapshotID, opts) - } else { - err = v.VerifySnapshotWithOptions(snapshotID, opts) - } - if err != nil { + if err := v.VerifySnapshotWithOptions(snapshotID, opts); err != nil { if err != context.Canceled { if !opts.JSON { log.Error("Verification failed", "error", err) diff --git a/internal/cli/verify.go b/internal/cli/verify.go deleted file mode 100644 index bafbee9..0000000 --- a/internal/cli/verify.go +++ /dev/null @@ -1,98 +0,0 @@ -package cli - -import ( - "context" - "os" - - "git.eeqj.de/sneak/vaultik/internal/log" - "git.eeqj.de/sneak/vaultik/internal/vaultik" - "github.com/spf13/cobra" - "go.uber.org/fx" -) - -// NewVerifyCommand creates the verify command -func NewVerifyCommand() *cobra.Command { - opts := &vaultik.VerifyOptions{} - - cmd := &cobra.Command{ - Use: "verify ", - Short: "Verify snapshot integrity", - Long: `Verifies that all blobs referenced in a snapshot exist and optionally verifies their contents. - -Shallow verification (default): -- Downloads and decompresses manifest -- Checks existence of all blobs in S3 -- Reports missing blobs - -Deep verification (--deep): -- Downloads and decrypts database -- Verifies blob lists match between manifest and database -- Downloads, decrypts, and decompresses each blob -- Verifies SHA256 hash of each chunk matches database -- Ensures chunks are ordered correctly - -The command will fail immediately on any verification error and exit with non-zero status.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - snapshotID := args[0] - - // Use unified config resolution - configPath, err := ResolveConfigPath() - if err != nil { - return err - } - - // Use the app framework for all verification - rootFlags := GetRootFlags() - return RunWithApp(cmd.Context(), AppOptions{ - ConfigPath: configPath, - LogOptions: log.LogOptions{ - Verbose: rootFlags.Verbose, - Debug: rootFlags.Debug, - Quiet: rootFlags.Quiet || opts.JSON, // Suppress log output in JSON mode - }, - Modules: []fx.Option{}, - Invokes: []fx.Option{ - fx.Invoke(func(v *vaultik.Vaultik, lc fx.Lifecycle) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - // Run the verify operation directly - go func() { - var err error - if opts.Deep { - err = v.RunDeepVerify(snapshotID, opts) - } else { - err = v.VerifySnapshotWithOptions(snapshotID, opts) - } - - if err != nil { - if err != context.Canceled { - if !opts.JSON { - log.Error("Verification failed", "error", err) - } - os.Exit(1) - } - } - if err := v.Shutdowner.Shutdown(); err != nil { - log.Error("Failed to shutdown", "error", err) - } - }() - return nil - }, - OnStop: func(ctx context.Context) error { - log.Debug("Stopping verify operation") - v.Cancel() - return nil - }, - }) - }), - }, - }) - }, - } - - cmd.Flags().BoolVar(&opts.Deep, "deep", false, "Perform deep verification by downloading and verifying all blob contents") - cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output verification results as JSON") - - return cmd -} diff --git a/internal/vaultik/helpers.go b/internal/vaultik/helpers.go index 58c7db7..9947caa 100644 --- a/internal/vaultik/helpers.go +++ b/internal/vaultik/helpers.go @@ -79,6 +79,33 @@ func parseSnapshotTimestamp(snapshotID string) (time.Time, error) { return timestamp.UTC(), nil } +// snapshotNameFromID extracts the snapshot name from a snapshot ID. +// Snapshot IDs are formatted as `__` (or +// `_` if no name was given). The hostname argument +// is used to disambiguate cases where the hostname itself contains +// underscores. Returns "" if the ID has no name component. +func snapshotNameFromID(snapshotID, hostname string) string { + // Strip the trailing `_` suffix. + idx := strings.LastIndex(snapshotID, "_") + if idx <= 0 { + return "" + } + prefix := snapshotID[:idx] + + // Strip the leading hostname prefix. + if !strings.HasPrefix(prefix, hostname) { + return "" + } + rest := prefix[len(hostname):] + if rest == "" { + return "" // No name component + } + if rest[0] == '_' { + return rest[1:] + } + return "" +} + // parseDuration parses a duration string with support for days func parseDuration(s string) (time.Duration, error) { // Check for days suffix diff --git a/internal/vaultik/integration_test.go b/internal/vaultik/integration_test.go index 503c87d..a1261bc 100644 --- a/internal/vaultik/integration_test.go +++ b/internal/vaultik/integration_test.go @@ -541,3 +541,142 @@ func TestBackupAndRestore(t *testing.T) { t.Log("Backup and restore test completed successfully") } + +// TestEndToEndFileStorage exercises the full backup → restore loop against the +// real `file://` storage backend (FileStorer) on a real OS filesystem. This is +// the closest local approximation of a production backup: encrypted blobs get +// written to disk, the metadata SQLite database is exported through the same +// blobgen pipeline as a real backup, and restoration reads them back through +// the public Vaultik.Restore entrypoint. It is the canonical end-to-end smoke +// test for 1.0. +func TestEndToEndFileStorage(t *testing.T) { + log.Initialize(log.Config{}) + + // Real OS filesystem (SQLite + FileStorer both need it). + fs := afero.NewOsFs() + tempDir, err := os.MkdirTemp("", "vaultik-e2e-") + require.NoError(t, err) + defer func() { _ = os.RemoveAll(tempDir) }() + + dataDir := filepath.Join(tempDir, "source") + storeDir := filepath.Join(tempDir, "remote") + restoreDir := filepath.Join(tempDir, "restored") + dbPath := filepath.Join(tempDir, "index.sqlite") + + // Write a representative mix of file sizes: + // - empty file + // - tiny text file + // - file just under chunk boundary + // - file forcing multiple chunks + // - nested subdirectories + chunkSize := int64(64 * 1024) + maxBlobSize := int64(512 * 1024) + + testFiles := map[string][]byte{ + filepath.Join(dataDir, "empty.txt"): {}, + filepath.Join(dataDir, "small.txt"): []byte("hello vaultik"), + filepath.Join(dataDir, "subdir", "medium.bin"): bytesPattern("medium-", int(chunkSize/2)), + filepath.Join(dataDir, "subdir", "large.bin"): bytesPattern("large-", int(chunkSize*4)), + filepath.Join(dataDir, "deep", "nest", "leaf.txt"): []byte("leaf"), + } + + for path, content := range testFiles { + require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, afero.WriteFile(fs, path, content, 0o644)) + } + + // FileStorer is the real-world local-disk backend. + storer, err := storage.NewFileStorer(storeDir) + require.NoError(t, err) + + agePublicKey := "age1ezrjmfpwsc95svdg0y54mums3zevgzu0x0ecq2f7tp8a05gl0sjq9q9wjg" + ageSecretKey := "AGE-SECRET-KEY-19CR5YSFW59HM4TLD6GXVEDMZFTVVF7PPHKUT68TXSFPK7APHXA2QS2NJA5" + + cfg := &config.Config{ + AgeRecipients: []string{agePublicKey}, + AgeSecretKey: ageSecretKey, + CompressionLevel: 3, + Hostname: "test-host", + } + + ctx := context.Background() + + db, err := database.New(ctx, dbPath) + require.NoError(t, err) + defer func() { _ = db.Close() }() + + repos := database.NewRepositories(db) + + sm := snapshot.NewSnapshotManager(snapshot.SnapshotManagerParams{ + Repos: repos, + Storage: storer, + Config: cfg, + }) + sm.SetFilesystem(fs) + + scanner := snapshot.NewScanner(snapshot.ScannerConfig{ + FS: fs, + Storage: storer, + ChunkSize: chunkSize, + MaxBlobSize: maxBlobSize, + CompressionLevel: cfg.CompressionLevel, + AgeRecipients: cfg.AgeRecipients, + Repositories: repos, + }) + + snapshotID, err := sm.CreateSnapshotWithName(ctx, cfg.Hostname, "e2e", "test-version", "test-git") + require.NoError(t, err) + + scanResult, err := scanner.Scan(ctx, dataDir, snapshotID) + require.NoError(t, err) + require.Greater(t, scanResult.FilesScanned, 0) + require.Greater(t, scanResult.BlobsCreated, 0) + + require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID)) + require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID)) + + // Verify the backup actually landed on disk under blobs/ and metadata/. + blobInfo, err := os.Stat(filepath.Join(storeDir, "blobs")) + require.NoError(t, err) + require.True(t, blobInfo.IsDir()) + metaInfo, err := os.Stat(filepath.Join(storeDir, "metadata", snapshotID)) + require.NoError(t, err) + require.True(t, metaInfo.IsDir()) + + // Tear down the source DB before restore — restore must work using only + // the remote bytes plus the secret key, with no help from the local index. + require.NoError(t, db.Close()) + + restoreVaultik := &vaultik.Vaultik{ + Config: cfg, + Storage: storer, + Fs: fs, + Stdout: io.Discard, + Stderr: io.Discard, + } + restoreVaultik.SetContext(ctx) + + require.NoError(t, restoreVaultik.Restore(&vaultik.RestoreOptions{ + SnapshotID: snapshotID, + TargetDir: restoreDir, + Verify: true, + })) + + // Byte-equality compare every original against its restored copy. + for origPath, expected := range testFiles { + restoredPath := filepath.Join(restoreDir, origPath) + got, err := afero.ReadFile(fs, restoredPath) + require.NoError(t, err, "restored file missing: %s", restoredPath) + require.Equalf(t, expected, got, "byte-equality failed for %s", origPath) + } +} + +// bytesPattern returns a deterministic byte slice of length n with a tag prefix, +// useful for forcing chunker behavior with reproducible content. +func bytesPattern(tag string, n int) []byte { + out := make([]byte, n) + for i := range out { + out[i] = byte(tag[i%len(tag)] ^ byte(i&0xff)) + } + return out +} diff --git a/internal/vaultik/snapshot.go b/internal/vaultik/snapshot.go index 943aace..b9d1142 100644 --- a/internal/vaultik/snapshot.go +++ b/internal/vaultik/snapshot.go @@ -82,6 +82,37 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error { _, _ = fmt.Fprintf(v.Stdout, "\nAll %d snapshots completed in %s\n", len(snapshotNames), time.Since(overallStartTime).Round(time.Second)) } + if opts.Prune { + if err := v.runPostBackupPrune(snapshotNames); err != nil { + return fmt.Errorf("post-backup prune: %w", err) + } + } + + return nil +} + +// runPostBackupPrune drops older snapshots of the given names (keeping only +// the latest of each) and removes orphan blobs from remote storage. Invoked +// when `snapshot create --prune` is used. +func (v *Vaultik) runPostBackupPrune(snapshotNames []string) error { + log.Info("Running post-backup prune", "snapshots", snapshotNames) + _, _ = fmt.Fprintln(v.Stdout, "\n=== Post-backup prune ===") + + purgeOpts := &PurgeOptions{ + KeepLatest: true, + Force: true, + Names: snapshotNames, + Quiet: true, + } + if err := v.PurgeSnapshots(purgeOpts); err != nil { + return fmt.Errorf("purging old snapshots: %w", err) + } + + pruneOpts := &PruneOptions{Force: true} + if err := v.PruneBlobs(pruneOpts); err != nil { + return fmt.Errorf("pruning orphaned blobs: %w", err) + } + return nil } @@ -298,11 +329,6 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna } _, _ = fmt.Fprintf(v.Stdout, "Duration: %s\n", formatDuration(snapshotDuration)) - if opts.Prune { - log.Info("Pruning enabled - will delete old snapshots after snapshot") - // TODO: Implement pruning - } - return nil } @@ -467,8 +493,20 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error { return w.Flush() } -// PurgeSnapshots removes old snapshots based on criteria -func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool) error { +// PurgeOptions configures snapshot purge behavior. +type PurgeOptions struct { + KeepLatest bool // Keep only the most recent snapshot per name + OlderThan string // Drop snapshots older than this duration (e.g. "30d", "6m", "1y") + Force bool // Skip confirmation prompt and noisy output + Names []string // If non-empty, only operate on snapshots with one of these names + Quiet bool // Suppress informational output (used by --prune flag) +} + +// PurgeSnapshots removes old snapshots based on criteria. +// Retention is per-snapshot-name: KeepLatest keeps the latest of EACH configured +// snapshot name, not the latest globally. This prevents `home` and `system` +// snapshots from cannibalizing each other. +func (v *Vaultik) PurgeSnapshots(opts *PurgeOptions) error { // Sync with remote first if err := v.syncWithRemote(); err != nil { return fmt.Errorf("syncing with remote: %w", err) @@ -480,16 +518,30 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool) return fmt.Errorf("listing snapshots: %w", err) } - // Convert to SnapshotInfo format, only including completed snapshots + // Convert to SnapshotInfo format, only including completed snapshots, + // optionally filtered by name. + hostname := v.shortHostname() + nameFilter := make(map[string]struct{}, len(opts.Names)) + for _, n := range opts.Names { + nameFilter[n] = struct{}{} + } + snapshots := make([]SnapshotInfo, 0, len(dbSnapshots)) for _, s := range dbSnapshots { - if s.CompletedAt != nil { - snapshots = append(snapshots, SnapshotInfo{ - ID: s.ID, - Timestamp: s.StartedAt, - CompressedSize: s.BlobSize, - }) + if s.CompletedAt == nil { + continue } + if len(nameFilter) > 0 { + name := snapshotNameFromID(s.ID.String(), hostname) + if _, ok := nameFilter[name]; !ok { + continue + } + } + snapshots = append(snapshots, SnapshotInfo{ + ID: s.ID, + Timestamp: s.StartedAt, + CompressedSize: s.BlobSize, + }) } // Sort by timestamp (newest first) @@ -499,14 +551,21 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool) var toDelete []SnapshotInfo - if keepLatest { - // Keep only the most recent snapshot - if len(snapshots) > 1 { - toDelete = snapshots[1:] + if opts.KeepLatest { + // Keep only the most recent snapshot of each name. Group by snapshot name + // (derived from snapshot ID) and keep the newest in each group. + seen := make(map[string]bool) + for _, snap := range snapshots { + name := snapshotNameFromID(snap.ID.String(), hostname) + if seen[name] { + toDelete = append(toDelete, snap) + continue + } + seen[name] = true } - } else if olderThan != "" { + } else if opts.OlderThan != "" { // Parse duration - duration, err := parseDuration(olderThan) + duration, err := parseDuration(opts.OlderThan) if err != nil { return fmt.Errorf("invalid duration: %w", err) } @@ -520,34 +579,38 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool) } if len(toDelete) == 0 { - fmt.Println("No snapshots to delete") + if !opts.Quiet { + _, _ = fmt.Fprintln(v.Stdout, "No snapshots to delete") + } return nil } // Show what will be deleted - fmt.Printf("The following snapshots will be deleted:\n\n") - for _, snap := range toDelete { - fmt.Printf(" %s (%s, %s)\n", - snap.ID, - snap.Timestamp.Format("2006-01-02 15:04:05"), - formatBytes(snap.CompressedSize)) + if !opts.Quiet { + _, _ = fmt.Fprintf(v.Stdout, "The following snapshots will be deleted:\n\n") + for _, snap := range toDelete { + _, _ = fmt.Fprintf(v.Stdout, " %s (%s, %s)\n", + snap.ID, + snap.Timestamp.Format("2006-01-02 15:04:05"), + formatBytes(snap.CompressedSize)) + } } // Confirm unless --force is used - if !force { - fmt.Printf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete)) + if !opts.Force { + _, _ = fmt.Fprintf(v.Stdout, "\nDelete %d snapshot(s)? [y/N] ", len(toDelete)) var confirm string - if _, err := fmt.Scanln(&confirm); err != nil { + if _, err := fmt.Fscanln(v.Stdin, &confirm); err != nil { // Treat EOF or error as "no" - fmt.Println("Cancelled") + _, _ = fmt.Fprintln(v.Stdout, "Cancelled") return nil } if strings.ToLower(confirm) != "y" { - fmt.Println("Cancelled") + _, _ = fmt.Fprintln(v.Stdout, "Cancelled") return nil } - } else { - fmt.Printf("\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete)) + } else if !opts.Quiet { + _, _ = fmt.Fprintf(v.Stdout, "\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete)) } // Delete snapshots (both local and remote) @@ -562,43 +625,49 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool) } } - fmt.Printf("Deleted %d snapshot(s)\n", len(toDelete)) - - // Note: Run 'vaultik prune' separately to clean up unreferenced blobs - fmt.Println("\nNote: Run 'vaultik prune' to clean up unreferenced blobs.") + if !opts.Quiet { + _, _ = fmt.Fprintf(v.Stdout, "Deleted %d snapshot(s)\n", len(toDelete)) + _, _ = fmt.Fprintln(v.Stdout, "\nNote: Run 'vaultik prune' to clean up unreferenced blobs.") + } return nil } +// shortHostname returns the configured hostname stripped of its domain suffix. +// This matches the hostname-prefix used when building snapshot IDs. +func (v *Vaultik) shortHostname() string { + hostname := v.Config.Hostname + if hostname == "" { + hostname, _ = os.Hostname() + } + if idx := strings.Index(hostname, "."); idx != -1 { + hostname = hostname[:idx] + } + return hostname +} + // VerifySnapshot checks snapshot integrity func (v *Vaultik) VerifySnapshot(snapshotID string, deep bool) error { return v.VerifySnapshotWithOptions(snapshotID, &VerifyOptions{Deep: deep}) } -// VerifySnapshotWithOptions checks snapshot integrity with full options +// VerifySnapshotWithOptions checks snapshot integrity with full options. +// Deep verification is delegated to RunDeepVerify so this function only +// implements the shallow (existence-only) path. func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptions) error { + if opts.Deep { + return v.RunDeepVerify(snapshotID, opts) + } result := &VerifyResult{ SnapshotID: snapshotID, Mode: "shallow", } - if opts.Deep { - result.Mode = "deep" - } - // Parse snapshot ID to extract timestamp - parts := strings.Split(snapshotID, "-") + // Parse snapshot ID to extract timestamp. + // Snapshot ID format: hostname[_name]_ var snapshotTime time.Time - if len(parts) >= 3 { - // Format: hostname-YYYYMMDD-HHMMSSZ - dateStr := parts[len(parts)-2] - timeStr := parts[len(parts)-1] - if len(dateStr) == 8 && len(timeStr) == 7 && strings.HasSuffix(timeStr, "Z") { - timeStr = timeStr[:6] // Remove Z - timestamp, err := time.Parse("20060102150405", dateStr+timeStr) - if err == nil { - snapshotTime = timestamp - } - } + if t, err := parseSnapshotTimestamp(snapshotID); err == nil { + snapshotTime = t } if !opts.JSON { @@ -645,25 +714,16 @@ func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptio for _, blob := range manifest.Blobs { blobPath := fmt.Sprintf("blobs/%s/%s/%s", blob.Hash[:2], blob.Hash[2:4], blob.Hash) - if opts.Deep { - // Download and verify hash - // TODO: Implement deep verification + // Shallow: just check existence + _, err := v.Storage.Stat(v.ctx, blobPath) + if err != nil { if !opts.JSON { - fmt.Printf("Deep verification not yet implemented\n") + fmt.Printf(" Missing: %s (%s)\n", blob.Hash, humanize.Bytes(uint64(blob.CompressedSize))) } - return nil + missing++ + missingSize += blob.CompressedSize } else { - // Just check existence - _, err := v.Storage.Stat(v.ctx, blobPath) - if err != nil { - if !opts.JSON { - fmt.Printf(" Missing: %s (%s)\n", blob.Hash, humanize.Bytes(uint64(blob.CompressedSize))) - } - missing++ - missingSize += blob.CompressedSize - } else { - verified++ - } + verified++ } } diff --git a/internal/vaultik/verify.go b/internal/vaultik/verify.go index 3c793db..6ebcb40 100644 --- a/internal/vaultik/verify.go +++ b/internal/vaultik/verify.go @@ -13,7 +13,7 @@ import ( "git.eeqj.de/sneak/vaultik/internal/snapshot" "github.com/dustin/go-humanize" "github.com/klauspost/compress/zstd" - _ "github.com/mattn/go-sqlite3" + _ "modernc.org/sqlite" ) // VerifyOptions contains options for the verify command @@ -272,7 +272,7 @@ func (v *Vaultik) decryptAndLoadDatabase(reader io.ReadCloser, secretKey string) log.Info("Database decompressed", "size", humanize.Bytes(uint64(written))) // Open the database - db, err := sql.Open("sqlite3", tempPath) + db, err := sql.Open("sqlite", tempPath) if err != nil { _ = os.Remove(tempPath) return nil, fmt.Errorf("failed to open database: %w", err)