6 Commits

Author SHA1 Message Date
162d76bb38 Merge branch 'main' into fix/issue-27 2026-02-16 06:17:51 +01:00
clawbot
bfd7334221 fix: replace table name allowlist with regex sanitization
Replace the hardcoded validTableNames allowlist with a regexp that
only allows [a-z0-9_] characters. This prevents SQL injection without
requiring maintenance of a separate allowlist when new tables are added.

Addresses review feedback from @sneak on PR #32.
2026-02-15 21:17:24 -08:00
user
9b32bf0846 fix: replace table name allowlist with regex sanitization
Replace the hardcoded validTableNames allowlist with a regexp that
only allows [a-z0-9_] characters. This prevents SQL injection without
requiring maintenance of a separate allowlist when new tables are added.

Addresses review feedback from @sneak on PR #32.
2026-02-15 21:15:49 -08:00
8adc668fa6 Merge pull request 'Prevent double-close of blobgen.Writer in CompressStream (closes #28)' (#33) from fix/issue-28 into main
Reviewed-on: #33
2026-02-16 06:04:33 +01:00
clawbot
441c441eca fix: prevent double-close of blobgen.Writer in CompressStream
CompressStream had both a defer w.Close() and an explicit w.Close() call,
causing the compressor and encryptor to be closed twice. The second close
on the zstd encoder returns an error, and the age encryptor may write
duplicate finalization bytes, potentially corrupting the output stream.

Use a closed flag to prevent the deferred close from running after the
explicit close succeeds.
2026-02-08 12:03:36 -08:00
clawbot
4d9f912a5f fix: validate table name against allowlist in getTableCount to prevent SQL injection
The getTableCount method used fmt.Sprintf to interpolate a table name directly
into a SQL query. While currently only called with hardcoded names, this is a
dangerous pattern. Added an allowlist of valid table names and return an error
for unrecognized names.
2026-02-08 12:03:18 -08:00
4 changed files with 153 additions and 136 deletions

View File

@@ -51,7 +51,13 @@ func CompressStream(dst io.Writer, src io.Reader, compressionLevel int, recipien
if err != nil { if err != nil {
return 0, "", fmt.Errorf("creating writer: %w", err) return 0, "", fmt.Errorf("creating writer: %w", err)
} }
defer func() { _ = w.Close() }()
closed := false
defer func() {
if !closed {
_ = w.Close()
}
}()
// Copy data // Copy data
if _, err := io.Copy(w, src); err != nil { if _, err := io.Copy(w, src); err != nil {
@@ -62,6 +68,7 @@ func CompressStream(dst io.Writer, src io.Reader, compressionLevel int, recipien
if err := w.Close(); err != nil { if err := w.Close(); err != nil {
return 0, "", fmt.Errorf("closing writer: %w", err) return 0, "", fmt.Errorf("closing writer: %w", err)
} }
closed = true
return w.BytesWritten(), hex.EncodeToString(w.Sum256()), nil return w.BytesWritten(), hex.EncodeToString(w.Sum256()), nil
} }

View File

@@ -15,99 +15,99 @@ import (
// ShowInfo displays system and configuration information // ShowInfo displays system and configuration information
func (v *Vaultik) ShowInfo() error { func (v *Vaultik) ShowInfo() error {
// System Information // System Information
_, _ = fmt.Fprintf(v.Stdout, "=== System Information ===\n") fmt.Printf("=== System Information ===\n")
_, _ = fmt.Fprintf(v.Stdout, "OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH) fmt.Printf("OS/Architecture: %s/%s\n", runtime.GOOS, runtime.GOARCH)
_, _ = fmt.Fprintf(v.Stdout, "Version: %s\n", v.Globals.Version) fmt.Printf("Version: %s\n", v.Globals.Version)
_, _ = fmt.Fprintf(v.Stdout, "Commit: %s\n", v.Globals.Commit) fmt.Printf("Commit: %s\n", v.Globals.Commit)
_, _ = fmt.Fprintf(v.Stdout, "Go Version: %s\n", runtime.Version()) fmt.Printf("Go Version: %s\n", runtime.Version())
_, _ = fmt.Fprintln(v.Stdout, ) fmt.Println()
// Storage Configuration // Storage Configuration
_, _ = fmt.Fprintf(v.Stdout, "=== Storage Configuration ===\n") fmt.Printf("=== Storage Configuration ===\n")
_, _ = fmt.Fprintf(v.Stdout, "S3 Bucket: %s\n", v.Config.S3.Bucket) fmt.Printf("S3 Bucket: %s\n", v.Config.S3.Bucket)
if v.Config.S3.Prefix != "" { if v.Config.S3.Prefix != "" {
_, _ = fmt.Fprintf(v.Stdout, "S3 Prefix: %s\n", v.Config.S3.Prefix) fmt.Printf("S3 Prefix: %s\n", v.Config.S3.Prefix)
} }
_, _ = fmt.Fprintf(v.Stdout, "S3 Endpoint: %s\n", v.Config.S3.Endpoint) fmt.Printf("S3 Endpoint: %s\n", v.Config.S3.Endpoint)
_, _ = fmt.Fprintf(v.Stdout, "S3 Region: %s\n", v.Config.S3.Region) fmt.Printf("S3 Region: %s\n", v.Config.S3.Region)
_, _ = fmt.Fprintln(v.Stdout, ) fmt.Println()
// Backup Settings // Backup Settings
_, _ = fmt.Fprintf(v.Stdout, "=== Backup Settings ===\n") fmt.Printf("=== Backup Settings ===\n")
// Show configured snapshots // Show configured snapshots
_, _ = fmt.Fprintf(v.Stdout, "Snapshots:\n") fmt.Printf("Snapshots:\n")
for _, name := range v.Config.SnapshotNames() { for _, name := range v.Config.SnapshotNames() {
snap := v.Config.Snapshots[name] snap := v.Config.Snapshots[name]
_, _ = fmt.Fprintf(v.Stdout, " %s:\n", name) fmt.Printf(" %s:\n", name)
for _, path := range snap.Paths { for _, path := range snap.Paths {
_, _ = fmt.Fprintf(v.Stdout, " - %s\n", path) fmt.Printf(" - %s\n", path)
} }
if len(snap.Exclude) > 0 { if len(snap.Exclude) > 0 {
_, _ = fmt.Fprintf(v.Stdout, " exclude: %s\n", strings.Join(snap.Exclude, ", ")) fmt.Printf(" exclude: %s\n", strings.Join(snap.Exclude, ", "))
} }
} }
// Global exclude patterns // Global exclude patterns
if len(v.Config.Exclude) > 0 { if len(v.Config.Exclude) > 0 {
_, _ = fmt.Fprintf(v.Stdout, "Global Exclude: %s\n", strings.Join(v.Config.Exclude, ", ")) fmt.Printf("Global Exclude: %s\n", strings.Join(v.Config.Exclude, ", "))
} }
_, _ = fmt.Fprintf(v.Stdout, "Compression: zstd level %d\n", v.Config.CompressionLevel) fmt.Printf("Compression: zstd level %d\n", v.Config.CompressionLevel)
_, _ = fmt.Fprintf(v.Stdout, "Chunk Size: %s\n", humanize.Bytes(uint64(v.Config.ChunkSize))) fmt.Printf("Chunk Size: %s\n", humanize.Bytes(uint64(v.Config.ChunkSize)))
_, _ = fmt.Fprintf(v.Stdout, "Blob Size Limit: %s\n", humanize.Bytes(uint64(v.Config.BlobSizeLimit))) fmt.Printf("Blob Size Limit: %s\n", humanize.Bytes(uint64(v.Config.BlobSizeLimit)))
_, _ = fmt.Fprintln(v.Stdout, ) fmt.Println()
// Encryption Configuration // Encryption Configuration
_, _ = fmt.Fprintf(v.Stdout, "=== Encryption Configuration ===\n") fmt.Printf("=== Encryption Configuration ===\n")
_, _ = fmt.Fprintf(v.Stdout, "Recipients:\n") fmt.Printf("Recipients:\n")
for _, recipient := range v.Config.AgeRecipients { for _, recipient := range v.Config.AgeRecipients {
_, _ = fmt.Fprintf(v.Stdout, " - %s\n", recipient) fmt.Printf(" - %s\n", recipient)
} }
_, _ = fmt.Fprintln(v.Stdout, ) fmt.Println()
// Daemon Settings (if applicable) // Daemon Settings (if applicable)
if v.Config.BackupInterval > 0 || v.Config.MinTimeBetweenRun > 0 { if v.Config.BackupInterval > 0 || v.Config.MinTimeBetweenRun > 0 {
_, _ = fmt.Fprintf(v.Stdout, "=== Daemon Settings ===\n") fmt.Printf("=== Daemon Settings ===\n")
if v.Config.BackupInterval > 0 { if v.Config.BackupInterval > 0 {
_, _ = fmt.Fprintf(v.Stdout, "Backup Interval: %s\n", v.Config.BackupInterval) fmt.Printf("Backup Interval: %s\n", v.Config.BackupInterval)
} }
if v.Config.MinTimeBetweenRun > 0 { if v.Config.MinTimeBetweenRun > 0 {
_, _ = fmt.Fprintf(v.Stdout, "Minimum Time: %s\n", v.Config.MinTimeBetweenRun) fmt.Printf("Minimum Time: %s\n", v.Config.MinTimeBetweenRun)
} }
_, _ = fmt.Fprintln(v.Stdout, ) fmt.Println()
} }
// Local Database // Local Database
_, _ = fmt.Fprintf(v.Stdout, "=== Local Database ===\n") fmt.Printf("=== Local Database ===\n")
_, _ = fmt.Fprintf(v.Stdout, "Index Path: %s\n", v.Config.IndexPath) fmt.Printf("Index Path: %s\n", v.Config.IndexPath)
// Check if index file exists and get its size // Check if index file exists and get its size
if info, err := v.Fs.Stat(v.Config.IndexPath); err == nil { if info, err := v.Fs.Stat(v.Config.IndexPath); err == nil {
_, _ = fmt.Fprintf(v.Stdout, "Index Size: %s\n", humanize.Bytes(uint64(info.Size()))) fmt.Printf("Index Size: %s\n", humanize.Bytes(uint64(info.Size())))
// Get snapshot count from database // Get snapshot count from database
query := `SELECT COUNT(*) FROM snapshots WHERE completed_at IS NOT NULL` query := `SELECT COUNT(*) FROM snapshots WHERE completed_at IS NOT NULL`
var snapshotCount int var snapshotCount int
if err := v.DB.Conn().QueryRowContext(v.ctx, query).Scan(&snapshotCount); err == nil { if err := v.DB.Conn().QueryRowContext(v.ctx, query).Scan(&snapshotCount); err == nil {
_, _ = fmt.Fprintf(v.Stdout, "Snapshots: %d\n", snapshotCount) fmt.Printf("Snapshots: %d\n", snapshotCount)
} }
// Get blob count from database // Get blob count from database
query = `SELECT COUNT(*) FROM blobs` query = `SELECT COUNT(*) FROM blobs`
var blobCount int var blobCount int
if err := v.DB.Conn().QueryRowContext(v.ctx, query).Scan(&blobCount); err == nil { if err := v.DB.Conn().QueryRowContext(v.ctx, query).Scan(&blobCount); err == nil {
_, _ = fmt.Fprintf(v.Stdout, "Blobs: %d\n", blobCount) fmt.Printf("Blobs: %d\n", blobCount)
} }
// Get file count from database // Get file count from database
query = `SELECT COUNT(*) FROM files` query = `SELECT COUNT(*) FROM files`
var fileCount int var fileCount int
if err := v.DB.Conn().QueryRowContext(v.ctx, query).Scan(&fileCount); err == nil { if err := v.DB.Conn().QueryRowContext(v.ctx, query).Scan(&fileCount); err == nil {
_, _ = fmt.Fprintf(v.Stdout, "Files: %d\n", fileCount) fmt.Printf("Files: %d\n", fileCount)
} }
} else { } else {
_, _ = fmt.Fprintf(v.Stdout, "Index Size: (not created)\n") fmt.Printf("Index Size: (not created)\n")
} }
return nil return nil
@@ -157,15 +157,15 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
result.StorageLocation = storageInfo.Location result.StorageLocation = storageInfo.Location
if !jsonOutput { if !jsonOutput {
_, _ = fmt.Fprintf(v.Stdout, "=== Remote Storage ===\n") fmt.Printf("=== Remote Storage ===\n")
_, _ = fmt.Fprintf(v.Stdout, "Type: %s\n", storageInfo.Type) fmt.Printf("Type: %s\n", storageInfo.Type)
_, _ = fmt.Fprintf(v.Stdout, "Location: %s\n", storageInfo.Location) fmt.Printf("Location: %s\n", storageInfo.Location)
_, _ = fmt.Fprintln(v.Stdout, ) fmt.Println()
} }
// List all snapshot metadata // List all snapshot metadata
if !jsonOutput { if !jsonOutput {
_, _ = fmt.Fprintf(v.Stdout, "Scanning snapshot metadata...\n") fmt.Printf("Scanning snapshot metadata...\n")
} }
snapshotMetadata := make(map[string]*SnapshotMetadataInfo) snapshotMetadata := make(map[string]*SnapshotMetadataInfo)
@@ -210,7 +210,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
// Download and parse all manifests to get referenced blobs // Download and parse all manifests to get referenced blobs
if !jsonOutput { if !jsonOutput {
_, _ = fmt.Fprintf(v.Stdout, "Downloading %d manifest(s)...\n", len(snapshotIDs)) fmt.Printf("Downloading %d manifest(s)...\n", len(snapshotIDs))
} }
referencedBlobs := make(map[string]int64) // hash -> compressed size referencedBlobs := make(map[string]int64) // hash -> compressed size
@@ -260,7 +260,7 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
// List all blobs on remote // List all blobs on remote
if !jsonOutput { if !jsonOutput {
_, _ = fmt.Fprintf(v.Stdout, "Scanning blobs...\n") fmt.Printf("Scanning blobs...\n")
} }
allBlobs := make(map[string]int64) // hash -> size from storage allBlobs := make(map[string]int64) // hash -> size from storage
@@ -298,14 +298,14 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
} }
// Human-readable output // Human-readable output
_, _ = fmt.Fprintf(v.Stdout, "\n=== Snapshot Metadata ===\n") fmt.Printf("\n=== Snapshot Metadata ===\n")
if len(result.Snapshots) == 0 { if len(result.Snapshots) == 0 {
_, _ = fmt.Fprintf(v.Stdout, "No snapshots found\n") fmt.Printf("No snapshots found\n")
} else { } else {
_, _ = fmt.Fprintf(v.Stdout, "%-45s %12s %12s %12s %10s %12s\n", "SNAPSHOT", "MANIFEST", "DATABASE", "TOTAL", "BLOBS", "BLOB SIZE") fmt.Printf("%-45s %12s %12s %12s %10s %12s\n", "SNAPSHOT", "MANIFEST", "DATABASE", "TOTAL", "BLOBS", "BLOB SIZE")
_, _ = fmt.Fprintf(v.Stdout, "%-45s %12s %12s %12s %10s %12s\n", strings.Repeat("-", 45), strings.Repeat("-", 12), strings.Repeat("-", 12), strings.Repeat("-", 12), strings.Repeat("-", 10), strings.Repeat("-", 12)) fmt.Printf("%-45s %12s %12s %12s %10s %12s\n", strings.Repeat("-", 45), strings.Repeat("-", 12), strings.Repeat("-", 12), strings.Repeat("-", 12), strings.Repeat("-", 10), strings.Repeat("-", 12))
for _, info := range result.Snapshots { for _, info := range result.Snapshots {
_, _ = fmt.Fprintf(v.Stdout, "%-45s %12s %12s %12s %10s %12s\n", fmt.Printf("%-45s %12s %12s %12s %10s %12s\n",
truncateString(info.SnapshotID, 45), truncateString(info.SnapshotID, 45),
humanize.Bytes(uint64(info.ManifestSize)), humanize.Bytes(uint64(info.ManifestSize)),
humanize.Bytes(uint64(info.DatabaseSize)), humanize.Bytes(uint64(info.DatabaseSize)),
@@ -314,23 +314,23 @@ func (v *Vaultik) RemoteInfo(jsonOutput bool) error {
humanize.Bytes(uint64(info.BlobsSize)), humanize.Bytes(uint64(info.BlobsSize)),
) )
} }
_, _ = fmt.Fprintf(v.Stdout, "%-45s %12s %12s %12s %10s %12s\n", strings.Repeat("-", 45), strings.Repeat("-", 12), strings.Repeat("-", 12), strings.Repeat("-", 12), strings.Repeat("-", 10), strings.Repeat("-", 12)) fmt.Printf("%-45s %12s %12s %12s %10s %12s\n", strings.Repeat("-", 45), strings.Repeat("-", 12), strings.Repeat("-", 12), strings.Repeat("-", 12), strings.Repeat("-", 10), strings.Repeat("-", 12))
_, _ = fmt.Fprintf(v.Stdout, "%-45s %12s %12s %12s\n", fmt.Sprintf("Total (%d snapshots)", result.TotalMetadataCount), "", "", humanize.Bytes(uint64(result.TotalMetadataSize))) fmt.Printf("%-45s %12s %12s %12s\n", fmt.Sprintf("Total (%d snapshots)", result.TotalMetadataCount), "", "", humanize.Bytes(uint64(result.TotalMetadataSize)))
} }
_, _ = fmt.Fprintf(v.Stdout, "\n=== Blob Storage ===\n") fmt.Printf("\n=== Blob Storage ===\n")
_, _ = fmt.Fprintf(v.Stdout, "Total blobs on remote: %s (%s)\n", fmt.Printf("Total blobs on remote: %s (%s)\n",
humanize.Comma(int64(result.TotalBlobCount)), humanize.Comma(int64(result.TotalBlobCount)),
humanize.Bytes(uint64(result.TotalBlobSize))) humanize.Bytes(uint64(result.TotalBlobSize)))
_, _ = fmt.Fprintf(v.Stdout, "Referenced by snapshots: %s (%s)\n", fmt.Printf("Referenced by snapshots: %s (%s)\n",
humanize.Comma(int64(result.ReferencedBlobCount)), humanize.Comma(int64(result.ReferencedBlobCount)),
humanize.Bytes(uint64(result.ReferencedBlobSize))) humanize.Bytes(uint64(result.ReferencedBlobSize)))
_, _ = fmt.Fprintf(v.Stdout, "Orphaned (unreferenced): %s (%s)\n", fmt.Printf("Orphaned (unreferenced): %s (%s)\n",
humanize.Comma(int64(result.OrphanedBlobCount)), humanize.Comma(int64(result.OrphanedBlobCount)),
humanize.Bytes(uint64(result.OrphanedBlobSize))) humanize.Bytes(uint64(result.OrphanedBlobSize)))
if result.OrphanedBlobCount > 0 { if result.OrphanedBlobCount > 0 {
_, _ = fmt.Fprintf(v.Stdout, "\nRun 'vaultik prune --remote' to remove orphaned blobs.\n") fmt.Printf("\nRun 'vaultik prune --remote' to remove orphaned blobs.\n")
} }
return nil return nil

View File

@@ -3,6 +3,7 @@ package vaultik
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"strings" "strings"
"git.eeqj.de/sneak/vaultik/internal/log" "git.eeqj.de/sneak/vaultik/internal/log"
@@ -120,29 +121,29 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
if len(unreferencedBlobs) == 0 { if len(unreferencedBlobs) == 0 {
log.Info("No unreferenced blobs found") log.Info("No unreferenced blobs found")
if opts.JSON { if opts.JSON {
return v.outputPruneBlobsJSON(result) return outputPruneBlobsJSON(result)
} }
_, _ = fmt.Fprintln(v.Stdout, "No unreferenced blobs to remove.") fmt.Println("No unreferenced blobs to remove.")
return nil return nil
} }
// Show what will be deleted // Show what will be deleted
log.Info("Found unreferenced blobs", "count", len(unreferencedBlobs), "total_size", humanize.Bytes(uint64(totalSize))) log.Info("Found unreferenced blobs", "count", len(unreferencedBlobs), "total_size", humanize.Bytes(uint64(totalSize)))
if !opts.JSON { if !opts.JSON {
_, _ = fmt.Fprintf(v.Stdout, "Found %d unreferenced blob(s) totaling %s\n", len(unreferencedBlobs), humanize.Bytes(uint64(totalSize))) fmt.Printf("Found %d unreferenced blob(s) totaling %s\n", len(unreferencedBlobs), humanize.Bytes(uint64(totalSize)))
} }
// Confirm unless --force is used (skip in JSON mode - require --force) // Confirm unless --force is used (skip in JSON mode - require --force)
if !opts.Force && !opts.JSON { if !opts.Force && !opts.JSON {
_, _ = fmt.Fprintf(v.Stdout, "\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs)) fmt.Printf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
var confirm string var confirm string
if _, err := fmt.Fscanln(v.Stdin, &confirm); err != nil { if _, err := fmt.Scanln(&confirm); err != nil {
// Treat EOF or error as "no" // Treat EOF or error as "no"
_, _ = fmt.Fprintln(v.Stdout, "Cancelled") fmt.Println("Cancelled")
return nil return nil
} }
if strings.ToLower(confirm) != "y" { if strings.ToLower(confirm) != "y" {
_, _ = fmt.Fprintln(v.Stdout, "Cancelled") fmt.Println("Cancelled")
return nil return nil
} }
} }
@@ -184,20 +185,20 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
) )
if opts.JSON { if opts.JSON {
return v.outputPruneBlobsJSON(result) return outputPruneBlobsJSON(result)
} }
_, _ = fmt.Fprintf(v.Stdout, "\nDeleted %d blob(s) totaling %s\n", deletedCount, humanize.Bytes(uint64(deletedSize))) fmt.Printf("\nDeleted %d blob(s) totaling %s\n", deletedCount, humanize.Bytes(uint64(deletedSize)))
if deletedCount < len(unreferencedBlobs) { if deletedCount < len(unreferencedBlobs) {
_, _ = fmt.Fprintf(v.Stdout, "Failed to delete %d blob(s)\n", len(unreferencedBlobs)-deletedCount) fmt.Printf("Failed to delete %d blob(s)\n", len(unreferencedBlobs)-deletedCount)
} }
return nil return nil
} }
// outputPruneBlobsJSON outputs the prune result as JSON // outputPruneBlobsJSON outputs the prune result as JSON
func (v *Vaultik) outputPruneBlobsJSON(result *PruneBlobsResult) error { func outputPruneBlobsJSON(result *PruneBlobsResult) error {
encoder := json.NewEncoder(v.Stdout) encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ") encoder.SetIndent("", " ")
return encoder.Encode(result) return encoder.Encode(result)
} }

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"regexp"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -86,7 +87,7 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
// Print overall summary if multiple snapshots // Print overall summary if multiple snapshots
if len(snapshotNames) > 1 { if len(snapshotNames) > 1 {
_, _ = fmt.Fprintf(v.Stdout, "\nAll %d snapshots completed in %s\n", len(snapshotNames), time.Since(overallStartTime).Round(time.Second)) v.printfStdout("\nAll %d snapshots completed in %s\n", len(snapshotNames), time.Since(overallStartTime).Round(time.Second))
} }
return nil return nil
@@ -99,7 +100,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna
snapConfig := v.Config.Snapshots[snapName] snapConfig := v.Config.Snapshots[snapName]
if total > 1 { if total > 1 {
_, _ = fmt.Fprintf(v.Stdout, "\n=== Snapshot %d/%d: %s ===\n", idx, total, snapName) v.printfStdout("\n=== Snapshot %d/%d: %s ===\n", idx, total, snapName)
} }
// Resolve source directories to absolute paths // Resolve source directories to absolute paths
@@ -152,7 +153,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna
return fmt.Errorf("creating snapshot: %w", err) return fmt.Errorf("creating snapshot: %w", err)
} }
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName) log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
_, _ = fmt.Fprintf(v.Stdout, "Beginning snapshot: %s\n", snapshotID) v.printfStdout("Beginning snapshot: %s\n", snapshotID)
for i, dir := range resolvedDirs { for i, dir := range resolvedDirs {
// Check if context is cancelled // Check if context is cancelled
@@ -164,7 +165,7 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna
} }
log.Info("Scanning directory", "path", dir) log.Info("Scanning directory", "path", dir)
_, _ = fmt.Fprintf(v.Stdout, "Beginning directory scan (%d/%d): %s\n", i+1, len(resolvedDirs), dir) v.printfStdout("Beginning directory scan (%d/%d): %s\n", i+1, len(resolvedDirs), dir)
result, err := scanner.Scan(v.ctx, dir, snapshotID) result, err := scanner.Scan(v.ctx, dir, snapshotID)
if err != nil { if err != nil {
return fmt.Errorf("failed to scan %s: %w", dir, err) return fmt.Errorf("failed to scan %s: %w", dir, err)
@@ -275,35 +276,35 @@ func (v *Vaultik) createNamedSnapshot(opts *SnapshotCreateOptions, hostname, sna
} }
// Print comprehensive summary // Print comprehensive summary
_, _ = fmt.Fprintf(v.Stdout, "=== Snapshot Complete ===\n") v.printfStdout("=== Snapshot Complete ===\n")
_, _ = fmt.Fprintf(v.Stdout, "ID: %s\n", snapshotID) v.printfStdout("ID: %s\n", snapshotID)
_, _ = fmt.Fprintf(v.Stdout, "Files: %s examined, %s to process, %s unchanged", v.printfStdout("Files: %s examined, %s to process, %s unchanged",
formatNumber(totalFiles), formatNumber(totalFiles),
formatNumber(totalFilesChanged), formatNumber(totalFilesChanged),
formatNumber(totalFilesSkipped)) formatNumber(totalFilesSkipped))
if totalFilesDeleted > 0 { if totalFilesDeleted > 0 {
_, _ = fmt.Fprintf(v.Stdout, ", %s deleted", formatNumber(totalFilesDeleted)) v.printfStdout(", %s deleted", formatNumber(totalFilesDeleted))
} }
_, _ = fmt.Fprintln(v.Stdout) v.printlnStdout()
_, _ = fmt.Fprintf(v.Stdout, "Data: %s total (%s to process)", v.printfStdout("Data: %s total (%s to process)",
humanize.Bytes(uint64(totalBytesAll)), humanize.Bytes(uint64(totalBytesAll)),
humanize.Bytes(uint64(totalBytesChanged))) humanize.Bytes(uint64(totalBytesChanged)))
if totalBytesDeleted > 0 { if totalBytesDeleted > 0 {
_, _ = fmt.Fprintf(v.Stdout, ", %s deleted", humanize.Bytes(uint64(totalBytesDeleted))) v.printfStdout(", %s deleted", humanize.Bytes(uint64(totalBytesDeleted)))
} }
_, _ = fmt.Fprintln(v.Stdout) v.printlnStdout()
if totalBlobsUploaded > 0 { if totalBlobsUploaded > 0 {
_, _ = fmt.Fprintf(v.Stdout, "Storage: %s compressed from %s (%.2fx)\n", v.printfStdout("Storage: %s compressed from %s (%.2fx)\n",
humanize.Bytes(uint64(totalBlobSizeCompressed)), humanize.Bytes(uint64(totalBlobSizeCompressed)),
humanize.Bytes(uint64(totalBlobSizeUncompressed)), humanize.Bytes(uint64(totalBlobSizeUncompressed)),
compressionRatio) compressionRatio)
_, _ = fmt.Fprintf(v.Stdout, "Upload: %d blobs, %s in %s (%s)\n", v.printfStdout("Upload: %d blobs, %s in %s (%s)\n",
totalBlobsUploaded, totalBlobsUploaded,
humanize.Bytes(uint64(totalBytesUploaded)), humanize.Bytes(uint64(totalBytesUploaded)),
formatDuration(uploadDuration), formatDuration(uploadDuration),
avgUploadSpeed) avgUploadSpeed)
} }
_, _ = fmt.Fprintf(v.Stdout, "Duration: %s\n", formatDuration(snapshotDuration)) v.printfStdout("Duration: %s\n", formatDuration(snapshotDuration))
if opts.Prune { if opts.Prune {
log.Info("Pruning enabled - will delete old snapshots after snapshot") log.Info("Pruning enabled - will delete old snapshots after snapshot")
@@ -527,14 +528,14 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool)
} }
if len(toDelete) == 0 { if len(toDelete) == 0 {
_, _ = fmt.Fprintln(v.Stdout, "No snapshots to delete") v.printlnStdout("No snapshots to delete")
return nil return nil
} }
// Show what will be deleted // Show what will be deleted
_, _ = fmt.Fprintf(v.Stdout, "The following snapshots will be deleted:\n\n") v.printfStdout("The following snapshots will be deleted:\n\n")
for _, snap := range toDelete { for _, snap := range toDelete {
_, _ = fmt.Fprintf(v.Stdout, " %s (%s, %s)\n", v.printfStdout(" %s (%s, %s)\n",
snap.ID, snap.ID,
snap.Timestamp.Format("2006-01-02 15:04:05"), snap.Timestamp.Format("2006-01-02 15:04:05"),
formatBytes(snap.CompressedSize)) formatBytes(snap.CompressedSize))
@@ -542,19 +543,19 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool)
// Confirm unless --force is used // Confirm unless --force is used
if !force { if !force {
_, _ = fmt.Fprintf(v.Stdout, "\nDelete %d snapshot(s)? [y/N] ", len(toDelete)) v.printfStdout("\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
var confirm string var confirm string
if _, err := fmt.Fscanln(v.Stdin, &confirm); err != nil { if _, err := fmt.Scanln(&confirm); err != nil {
// Treat EOF or error as "no" // Treat EOF or error as "no"
_, _ = fmt.Fprintln(v.Stdout, "Cancelled") v.printlnStdout("Cancelled")
return nil return nil
} }
if strings.ToLower(confirm) != "y" { if strings.ToLower(confirm) != "y" {
_, _ = fmt.Fprintln(v.Stdout, "Cancelled") v.printlnStdout("Cancelled")
return nil return nil
} }
} else { } else {
_, _ = fmt.Fprintf(v.Stdout, "\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete)) v.printfStdout("\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete))
} }
// Delete snapshots (both local and remote) // Delete snapshots (both local and remote)
@@ -569,10 +570,10 @@ func (v *Vaultik) PurgeSnapshots(keepLatest bool, olderThan string, force bool)
} }
} }
_, _ = fmt.Fprintf(v.Stdout, "Deleted %d snapshot(s)\n", len(toDelete)) v.printfStdout("Deleted %d snapshot(s)\n", len(toDelete))
// Note: Run 'vaultik prune' separately to clean up unreferenced blobs // Note: Run 'vaultik prune' separately to clean up unreferenced blobs
_, _ = fmt.Fprintln(v.Stdout, "\nNote: Run 'vaultik prune' to clean up unreferenced blobs.") v.printlnStdout("\nNote: Run 'vaultik prune' to clean up unreferenced blobs.")
return nil return nil
} }
@@ -613,11 +614,11 @@ func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptio
} }
if !opts.JSON { if !opts.JSON {
_, _ = fmt.Fprintf(v.Stdout, "Verifying snapshot %s\n", snapshotID) v.printfStdout("Verifying snapshot %s\n", snapshotID)
if !snapshotTime.IsZero() { if !snapshotTime.IsZero() {
_, _ = fmt.Fprintf(v.Stdout, "Snapshot time: %s\n", snapshotTime.Format("2006-01-02 15:04:05 MST")) v.printfStdout("Snapshot time: %s\n", snapshotTime.Format("2006-01-02 15:04:05 MST"))
} }
fmt.Println() v.printlnStdout()
} }
// Download and parse manifest // Download and parse manifest
@@ -635,18 +636,18 @@ func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptio
result.TotalSize = manifest.TotalCompressedSize result.TotalSize = manifest.TotalCompressedSize
if !opts.JSON { if !opts.JSON {
_, _ = fmt.Fprintf(v.Stdout, "Snapshot information:\n") v.printfStdout("Snapshot information:\n")
_, _ = fmt.Fprintf(v.Stdout, " Blob count: %d\n", manifest.BlobCount) v.printfStdout(" Blob count: %d\n", manifest.BlobCount)
_, _ = fmt.Fprintf(v.Stdout, " Total size: %s\n", humanize.Bytes(uint64(manifest.TotalCompressedSize))) v.printfStdout(" Total size: %s\n", humanize.Bytes(uint64(manifest.TotalCompressedSize)))
if manifest.Timestamp != "" { if manifest.Timestamp != "" {
if t, err := time.Parse(time.RFC3339, manifest.Timestamp); err == nil { if t, err := time.Parse(time.RFC3339, manifest.Timestamp); err == nil {
_, _ = fmt.Fprintf(v.Stdout, " Created: %s\n", t.Format("2006-01-02 15:04:05 MST")) v.printfStdout(" Created: %s\n", t.Format("2006-01-02 15:04:05 MST"))
} }
} }
_, _ = fmt.Fprintln(v.Stdout) v.printlnStdout()
// Check each blob exists // Check each blob exists
_, _ = fmt.Fprintf(v.Stdout, "Checking blob existence...\n") v.printfStdout("Checking blob existence...\n")
} }
missing := 0 missing := 0
@@ -660,7 +661,7 @@ func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptio
_, err := v.Storage.Stat(v.ctx, blobPath) _, err := v.Storage.Stat(v.ctx, blobPath)
if err != nil { if err != nil {
if !opts.JSON { if !opts.JSON {
_, _ = fmt.Fprintf(v.Stdout, " Missing: %s (%s)\n", blob.Hash, humanize.Bytes(uint64(blob.CompressedSize))) v.printfStdout(" Missing: %s (%s)\n", blob.Hash, humanize.Bytes(uint64(blob.CompressedSize)))
} }
missing++ missing++
missingSize += blob.CompressedSize missingSize += blob.CompressedSize
@@ -683,20 +684,20 @@ func (v *Vaultik) VerifySnapshotWithOptions(snapshotID string, opts *VerifyOptio
return v.outputVerifyJSON(result) return v.outputVerifyJSON(result)
} }
_, _ = fmt.Fprintf(v.Stdout, "\nVerification complete:\n") v.printfStdout("\nVerification complete:\n")
_, _ = fmt.Fprintf(v.Stdout, " Verified: %d blobs (%s)\n", verified, v.printfStdout(" Verified: %d blobs (%s)\n", verified,
humanize.Bytes(uint64(manifest.TotalCompressedSize-missingSize))) humanize.Bytes(uint64(manifest.TotalCompressedSize-missingSize)))
if missing > 0 { if missing > 0 {
_, _ = fmt.Fprintf(v.Stdout, " Missing: %d blobs (%s)\n", missing, humanize.Bytes(uint64(missingSize))) v.printfStdout(" Missing: %d blobs (%s)\n", missing, humanize.Bytes(uint64(missingSize)))
} else { } else {
_, _ = fmt.Fprintf(v.Stdout, " Missing: 0 blobs\n") v.printfStdout(" Missing: 0 blobs\n")
} }
_, _ = fmt.Fprintf(v.Stdout, " Status: ") v.printfStdout(" Status: ")
if missing > 0 { if missing > 0 {
_, _ = fmt.Fprintf(v.Stdout, "FAILED - %d blobs are missing\n", missing) v.printfStdout("FAILED - %d blobs are missing\n", missing)
return fmt.Errorf("%d blobs are missing", missing) return fmt.Errorf("%d blobs are missing", missing)
} else { } else {
_, _ = fmt.Fprintf(v.Stdout, "OK - All blobs verified\n") v.printfStdout("OK - All blobs verified\n")
} }
return nil return nil
@@ -830,11 +831,11 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov
if opts.DryRun { if opts.DryRun {
result.DryRun = true result.DryRun = true
if !opts.JSON { if !opts.JSON {
_, _ = fmt.Fprintf(v.Stdout, "Would remove snapshot: %s\n", snapshotID) v.printfStdout("Would remove snapshot: %s\n", snapshotID)
if opts.Remote { if opts.Remote {
_, _ = fmt.Fprintln(v.Stdout, "Would also remove from remote storage") v.printlnStdout("Would also remove from remote storage")
} }
_, _ = fmt.Fprintln(v.Stdout, "[Dry run - no changes made]") v.printlnStdout("[Dry run - no changes made]")
} }
if opts.JSON { if opts.JSON {
return result, v.outputRemoveJSON(result) return result, v.outputRemoveJSON(result)
@@ -845,17 +846,17 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov
// Confirm unless --force is used (skip in JSON mode - require --force) // Confirm unless --force is used (skip in JSON mode - require --force)
if !opts.Force && !opts.JSON { if !opts.Force && !opts.JSON {
if opts.Remote { if opts.Remote {
_, _ = fmt.Fprintf(v.Stdout, "Remove snapshot '%s' from local database and remote storage? [y/N] ", snapshotID) v.printfStdout("Remove snapshot '%s' from local database and remote storage? [y/N] ", snapshotID)
} else { } else {
_, _ = fmt.Fprintf(v.Stdout, "Remove snapshot '%s' from local database? [y/N] ", snapshotID) v.printfStdout("Remove snapshot '%s' from local database? [y/N] ", snapshotID)
} }
var confirm string var confirm string
if _, err := fmt.Fscanln(v.Stdin, &confirm); err != nil { if err := v.scanlnStdin(&confirm); err != nil {
_, _ = fmt.Fprintln(v.Stdout, "Cancelled") v.printlnStdout("Cancelled")
return result, nil return result, nil
} }
if strings.ToLower(confirm) != "y" { if strings.ToLower(confirm) != "y" {
_, _ = fmt.Fprintln(v.Stdout, "Cancelled") v.printlnStdout("Cancelled")
return result, nil return result, nil
} }
} }
@@ -882,10 +883,10 @@ func (v *Vaultik) RemoveSnapshot(snapshotID string, opts *RemoveOptions) (*Remov
} }
// Print summary // Print summary
_, _ = fmt.Fprintf(v.Stdout, "Removed snapshot '%s' from local database\n", snapshotID) v.printfStdout("Removed snapshot '%s' from local database\n", snapshotID)
if opts.Remote { if opts.Remote {
_, _ = fmt.Fprintln(v.Stdout, "Removed snapshot metadata from remote storage") v.printlnStdout("Removed snapshot metadata from remote storage")
_, _ = fmt.Fprintln(v.Stdout, "\nNote: Blobs were not removed. Run 'vaultik prune' to remove orphaned blobs.") v.printlnStdout("\nNote: Blobs were not removed. Run 'vaultik prune' to remove orphaned blobs.")
} }
return result, nil return result, nil
@@ -929,7 +930,7 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
if len(snapshotIDs) == 0 { if len(snapshotIDs) == 0 {
if !opts.JSON { if !opts.JSON {
_, _ = fmt.Fprintln(v.Stdout, "No snapshots found") v.printlnStdout("No snapshots found")
} }
return result, nil return result, nil
} }
@@ -938,14 +939,14 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
result.DryRun = true result.DryRun = true
result.SnapshotsRemoved = snapshotIDs result.SnapshotsRemoved = snapshotIDs
if !opts.JSON { if !opts.JSON {
_, _ = fmt.Fprintf(v.Stdout, "Would remove %d snapshot(s):\n", len(snapshotIDs)) v.printfStdout("Would remove %d snapshot(s):\n", len(snapshotIDs))
for _, id := range snapshotIDs { for _, id := range snapshotIDs {
_, _ = fmt.Fprintf(v.Stdout, " %s\n", id) v.printfStdout(" %s\n", id)
} }
if opts.Remote { if opts.Remote {
_, _ = fmt.Fprintln(v.Stdout, "Would also remove from remote storage") v.printlnStdout("Would also remove from remote storage")
} }
_, _ = fmt.Fprintln(v.Stdout, "[Dry run - no changes made]") v.printlnStdout("[Dry run - no changes made]")
} }
if opts.JSON { if opts.JSON {
return result, v.outputRemoveJSON(result) return result, v.outputRemoveJSON(result)
@@ -986,10 +987,10 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
return result, v.outputRemoveJSON(result) return result, v.outputRemoveJSON(result)
} }
_, _ = fmt.Fprintf(v.Stdout, "Removed %d snapshot(s)\n", len(result.SnapshotsRemoved)) v.printfStdout("Removed %d snapshot(s)\n", len(result.SnapshotsRemoved))
if opts.Remote { if opts.Remote {
_, _ = fmt.Fprintln(v.Stdout, "Removed snapshot metadata from remote storage") v.printlnStdout("Removed snapshot metadata from remote storage")
_, _ = fmt.Fprintln(v.Stdout, "\nNote: Blobs were not removed. Run 'vaultik prune' to remove orphaned blobs.") v.printlnStdout("\nNote: Blobs were not removed. Run 'vaultik prune' to remove orphaned blobs.")
} }
return result, nil return result, nil
@@ -1117,21 +1118,29 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
) )
// Print summary // Print summary
_, _ = fmt.Fprintf(v.Stdout, "Local database prune complete:\n") v.printfStdout("Local database prune complete:\n")
_, _ = fmt.Fprintf(v.Stdout, " Incomplete snapshots removed: %d\n", result.SnapshotsDeleted) v.printfStdout(" Incomplete snapshots removed: %d\n", result.SnapshotsDeleted)
_, _ = fmt.Fprintf(v.Stdout, " Orphaned files removed: %d\n", result.FilesDeleted) v.printfStdout(" Orphaned files removed: %d\n", result.FilesDeleted)
_, _ = fmt.Fprintf(v.Stdout, " Orphaned chunks removed: %d\n", result.ChunksDeleted) v.printfStdout(" Orphaned chunks removed: %d\n", result.ChunksDeleted)
_, _ = fmt.Fprintf(v.Stdout, " Orphaned blobs removed: %d\n", result.BlobsDeleted) v.printfStdout(" Orphaned blobs removed: %d\n", result.BlobsDeleted)
return result, nil return result, nil
} }
// getTableCount returns the count of rows in a table // validTableNameRe matches table names containing only lowercase alphanumeric characters and underscores.
var validTableNameRe = regexp.MustCompile(`^[a-z0-9_]+$`)
// getTableCount returns the count of rows in a table.
// The tableName is sanitized to only allow [a-z0-9_] characters to prevent SQL injection.
func (v *Vaultik) getTableCount(tableName string) (int64, error) { func (v *Vaultik) getTableCount(tableName string) (int64, error) {
if v.DB == nil { if v.DB == nil {
return 0, nil return 0, nil
} }
if !validTableNameRe.MatchString(tableName) {
return 0, fmt.Errorf("invalid table name: %q", tableName)
}
var count int64 var count int64
query := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableName) query := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableName)
err := v.DB.Conn().QueryRowContext(v.ctx, query).Scan(&count) err := v.DB.Conn().QueryRowContext(v.ctx, query).Scan(&count)