diff --git a/README.md b/README.md index b31d33a..5cd225b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A CLI for working with blob archives in OCI registries. Push directories to regi - **Policy verification** for signatures and SLSA provenance attestations - **Interactive TUI** file browser for exploring archives - **Alias support** for frequently used references -- **Content caching** with deduplication across archives +- **Multi-layer caching** with content deduplication, manifest caching, and per-cache control ## Installation @@ -154,6 +154,58 @@ policies: | `BLOB_PASSWORD` | Registry password | | `NO_COLOR` | Disable colored output | +## Caching + +Blob maintains several caches to improve performance and reduce bandwidth usage: + +| Cache | Description | +|-------|-------------| +| `content` | File content cache (deduplicated by hash across archives) | +| `blocks` | HTTP range block cache | +| `refs` | Tag to digest mappings | +| `manifests` | OCI manifest cache | +| `indexes` | Archive index cache | + +Cache location follows XDG Base Directory Specification (`~/.cache/blob` by default). + +### Cache Commands + +```bash +# Show cache sizes and file counts +blob cache status + +# Show cache directory paths +blob cache path + +# Clear all caches +blob cache clear + +# Clear a specific cache type +blob cache clear indexes +``` + +### Cache Configuration + +```yaml +# ~/.config/blob/config.yaml +cache: + enabled: true + dir: /custom/cache/path # Optional: override cache location + ref_ttl: 5m # TTL for tag-to-digest cache (default: 5m) + + # Per-cache control (all enabled by default when cache.enabled is true) + content: + enabled: true + blocks: + enabled: true + refs: + enabled: true + manifests: + enabled: true + indexes: + enabled: false # Disable specific cache types +``` + ## Signing and Verification ### Sign an archive diff --git a/cmd/cache/cache.go b/cmd/cache/cache.go index 628f46b..51612c3 100644 --- a/cmd/cache/cache.go +++ b/cmd/cache/cache.go @@ -11,8 +11,15 @@ var Cmd = &cobra.Command{ Blob maintains several caches to improve performance: - content: File content cache (deduplicated across archives) + - blocks: HTTP range block cache + - refs: Tag to digest mappings - manifests: OCI manifest cache - - indexes: Archive index cache`, + - indexes: Archive index cache + +Cache location follows XDG Base Directory Specification: +$XDG_CACHE_HOME/blob or ~/.cache/blob by default. + +Override with cache.dir in config file or BLOB_CACHE_DIR environment variable.`, } func init() { diff --git a/cmd/cache/clear.go b/cmd/cache/clear.go index b46932e..af44f2f 100644 --- a/cmd/cache/clear.go +++ b/cmd/cache/clear.go @@ -1,7 +1,20 @@ package cache import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/meigma/blob-cli/internal/archive" + internalcfg "github.com/meigma/blob-cli/internal/config" ) var clearCmd = &cobra.Command{ @@ -11,6 +24,8 @@ var clearCmd = &cobra.Command{ Cache types: content File content cache (deduplicated across archives) + blocks HTTP range block cache + refs Tag to digest mappings manifests OCI manifest cache indexes Archive index cache all All caches (default)`, @@ -19,11 +34,208 @@ Cache types: blob cache clear content # Clear only content cache blob cache clear manifests # Clear only manifest cache`, Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return nil - }, + RunE: runClear, } func init() { clearCmd.Flags().Bool("force", false, "skip confirmation prompt") } + +// clearResult contains the clear output data. +type clearResult struct { + Cleared []string `json:"cleared"` + TotalSize int64 `json:"total_size_cleared"` + TotalHuman string `json:"total_size_human"` + TotalFiles int `json:"total_files_cleared"` +} + +func runClear(cmd *cobra.Command, args []string) error { + cfg := internalcfg.FromContext(cmd.Context()) + if cfg == nil { + return errors.New("configuration not loaded") + } + + targetType, typesToClear, err := parseClearArgs(args) + if err != nil { + return err + } + + force, err := cmd.Flags().GetBool("force") + if err != nil { + return fmt.Errorf("reading force flag: %w", err) + } + + cacheDir, err := resolveCacheDir(cfg) + if err != nil { + return fmt.Errorf("determining cache directory: %w", err) + } + + totalSize, totalFiles := calculateCacheSizes(cacheDir, typesToClear) + + // Require --force for non-interactive (JSON) output + if viper.GetString("output") == internalcfg.OutputJSON && !force { + return errors.New("--force required when using --output json") + } + + if !force && !cfg.Quiet { + confirmed, promptErr := promptClearConfirmation(targetType, totalSize, totalFiles) + if promptErr != nil { + return promptErr + } + if !confirmed { + fmt.Fprintln(os.Stderr, "Canceled.") + return nil + } + } + + result, err := executeClear(cacheDir, typesToClear, totalSize, totalFiles) + if err != nil { + return err + } + + return outputClearResult(cfg, result) +} + +// parseClearArgs parses and validates the cache type argument. +func parseClearArgs(args []string) (string, []cacheType, error) { + targetType := cacheTypeAll + if len(args) > 0 { + targetType = args[0] + } + + if !validCacheType(targetType) { + return "", nil, fmt.Errorf("invalid cache type %q, valid types: %s", targetType, strings.Join(cacheTypeNames(), ", ")) + } + + var typesToClear []cacheType + if targetType == cacheTypeAll { + typesToClear = cacheTypes + } else { + for _, ct := range cacheTypes { + if ct.Name == targetType { + typesToClear = []cacheType{ct} + break + } + } + } + + return targetType, typesToClear, nil +} + +// calculateCacheSizes calculates total size and file count for the given cache types. +func calculateCacheSizes(cacheDir string, types []cacheType) (totalSize int64, totalFiles int) { + for _, ct := range types { + path := filepath.Join(cacheDir, ct.SubDir) + totalSize += getDirSize(path) + totalFiles += countFiles(path) + } + return totalSize, totalFiles +} + +// promptClearConfirmation prompts the user for confirmation. +// Returns false (not confirmed) on EOF or non-interactive stdin. +func promptClearConfirmation(targetType string, totalSize int64, totalFiles int) (bool, error) { + typeDesc := targetType + " cache" + if targetType == cacheTypeAll { + typeDesc = "all caches" + } + + fmt.Printf("Clear %s? (%s, %d files) [y/N]: ", + typeDesc, + archive.FormatSize(uint64(max(0, totalSize))), //nolint:gosec // size is always non-negative + totalFiles) + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + // Treat EOF (non-interactive, piped stdin) as "no" + if errors.Is(err, io.EOF) { + fmt.Println() // newline since user didn't press enter + return false, nil + } + return false, fmt.Errorf("reading response: %w", err) + } + response = strings.ToLower(strings.TrimSpace(response)) + return response == "y" || response == "yes", nil +} + +// executeClear clears the specified cache types. +func executeClear(cacheDir string, types []cacheType, totalSize int64, totalFiles int) (*clearResult, error) { + result := &clearResult{ + Cleared: make([]string, 0, len(types)), + TotalSize: totalSize, + TotalHuman: archive.FormatSize(uint64(max(0, totalSize))), //nolint:gosec // size is always non-negative + TotalFiles: totalFiles, + } + + for _, ct := range types { + path := filepath.Join(cacheDir, ct.SubDir) + if err := clearDirectory(path); err != nil { + return nil, fmt.Errorf("clearing %s cache: %w", ct.Name, err) + } + result.Cleared = append(result.Cleared, ct.Name) + } + + return result, nil +} + +// outputClearResult outputs the clear result in the appropriate format. +func outputClearResult(cfg *internalcfg.Config, result *clearResult) error { + if cfg.Quiet { + return nil + } + if viper.GetString("output") == internalcfg.OutputJSON { + return clearJSON(result) + } + return clearText(result) +} + +// clearDirectory removes all contents of a directory but keeps the directory itself. +func clearDirectory(dir string) error { + // Check if directory exists + info, err := os.Stat(dir) + if os.IsNotExist(err) { + return nil // Nothing to clear + } + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", dir) + } + + // Read directory entries + entries, err := os.ReadDir(dir) + if err != nil { + return err + } + + // Remove each entry + for _, entry := range entries { + path := filepath.Join(dir, entry.Name()) + if err := os.RemoveAll(path); err != nil { + return err + } + } + + return nil +} + +func clearJSON(result *clearResult) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) +} + +func clearText(result *clearResult) error { + if len(result.Cleared) == 0 { + fmt.Println("No caches to clear.") + return nil + } + + fmt.Printf("Cleared %s (%d files)\n", result.TotalHuman, result.TotalFiles) + for _, name := range result.Cleared { + fmt.Printf(" - %s\n", name) + } + return nil +} diff --git a/cmd/cache/clear_test.go b/cmd/cache/clear_test.go new file mode 100644 index 0000000..5718329 --- /dev/null +++ b/cmd/cache/clear_test.go @@ -0,0 +1,224 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseClearArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantType string + wantCount int + wantErr bool + errContains string + }{ + { + name: "no args defaults to all", + args: []string{}, + wantType: cacheTypeAll, + wantCount: len(cacheTypes), + }, + { + name: "explicit all", + args: []string{"all"}, + wantType: cacheTypeAll, + wantCount: len(cacheTypes), + }, + { + name: "content type", + args: []string{"content"}, + wantType: "content", + wantCount: 1, + }, + { + name: "blocks type", + args: []string{"blocks"}, + wantType: "blocks", + wantCount: 1, + }, + { + name: "invalid type", + args: []string{"invalid"}, + wantErr: true, + errContains: "invalid cache type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotType, gotTypes, err := parseClearArgs(tt.args) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errContains != "" && !contains(err.Error(), tt.errContains) { + t.Errorf("error %q should contain %q", err.Error(), tt.errContains) + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotType != tt.wantType { + t.Errorf("parseClearArgs() type = %q, want %q", gotType, tt.wantType) + } + + if len(gotTypes) != tt.wantCount { + t.Errorf("parseClearArgs() types count = %d, want %d", len(gotTypes), tt.wantCount) + } + }) + } +} + +func TestClearDirectory(t *testing.T) { + t.Parallel() + + t.Run("clears files but keeps directory", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create test files + for i := range 3 { + if err := os.WriteFile(filepath.Join(dir, "file"+string(rune('a'+i))), []byte("content"), 0o644); err != nil { + t.Fatal(err) + } + } + + // Clear the directory + if err := clearDirectory(dir); err != nil { + t.Fatalf("clearDirectory() error: %v", err) + } + + // Directory should still exist + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("directory should exist: %v", err) + } + if !info.IsDir() { + t.Error("should still be a directory") + } + + // Should be empty + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir() error: %v", err) + } + if len(entries) != 0 { + t.Errorf("directory should be empty, has %d entries", len(entries)) + } + }) + + t.Run("clears nested directories", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create nested structure + subdir := filepath.Join(dir, "subdir", "nested") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subdir, "file.txt"), []byte("content"), 0o644); err != nil { + t.Fatal(err) + } + + // Clear the directory + if err := clearDirectory(dir); err != nil { + t.Fatalf("clearDirectory() error: %v", err) + } + + // Should be empty + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir() error: %v", err) + } + if len(entries) != 0 { + t.Errorf("directory should be empty, has %d entries", len(entries)) + } + }) + + t.Run("nonexistent directory returns nil", func(t *testing.T) { + t.Parallel() + err := clearDirectory("/nonexistent/path/that/does/not/exist") + if err != nil { + t.Errorf("clearDirectory(nonexistent) should return nil, got: %v", err) + } + }) + + t.Run("empty directory returns nil", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + err := clearDirectory(dir) + if err != nil { + t.Errorf("clearDirectory(empty) should return nil, got: %v", err) + } + }) +} + +func TestCalculateCacheSizes(t *testing.T) { + t.Parallel() + + t.Run("empty cache directories", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + types := []cacheType{{Name: "test", SubDir: "test"}} + size, files := calculateCacheSizes(dir, types) + + if size != 0 { + t.Errorf("size = %d, want 0", size) + } + if files != 0 { + t.Errorf("files = %d, want 0", files) + } + }) + + t.Run("cache with files", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create test subdir with files + testDir := filepath.Join(dir, "test") + if err := os.MkdirAll(testDir, 0o755); err != nil { + t.Fatal(err) + } + content := []byte("test content") + if err := os.WriteFile(filepath.Join(testDir, "file.txt"), content, 0o644); err != nil { + t.Fatal(err) + } + + types := []cacheType{{Name: "test", SubDir: "test"}} + size, files := calculateCacheSizes(dir, types) + + if size != int64(len(content)) { + t.Errorf("size = %d, want %d", size, len(content)) + } + if files != 1 { + t.Errorf("files = %d, want 1", files) + } + }) +} + +// contains checks if s contains substr (simple helper). +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || s != "" && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := range len(s) - len(substr) + 1 { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/cmd/cache/path.go b/cmd/cache/path.go index 89f9692..20ee696 100644 --- a/cmd/cache/path.go +++ b/cmd/cache/path.go @@ -1,7 +1,16 @@ package cache import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + internalcfg "github.com/meigma/blob-cli/internal/config" ) var pathCmd = &cobra.Command{ @@ -14,7 +23,55 @@ Base Directory Specification.`, Example: ` blob cache path blob cache path --output json`, Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: runPath, +} + +// pathResult contains the path output data. +type pathResult struct { + Root string `json:"root"` + Paths map[string]string `json:"paths"` +} + +func runPath(cmd *cobra.Command, _ []string) error { + cfg := internalcfg.FromContext(cmd.Context()) + if cfg == nil { + return errors.New("configuration not loaded") + } + + cacheDir, err := resolveCacheDir(cfg) + if err != nil { + return fmt.Errorf("determining cache directory: %w", err) + } + + result := pathResult{ + Root: cacheDir, + Paths: make(map[string]string, len(cacheTypes)), + } + for _, ct := range cacheTypes { + result.Paths[ct.Name] = filepath.Join(cacheDir, ct.SubDir) + } + + if cfg.Quiet { return nil - }, + } + + if viper.GetString("output") == internalcfg.OutputJSON { + return pathJSON(&result) + } + return pathText(&result) +} + +func pathJSON(result *pathResult) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) +} + +func pathText(result *pathResult) error { + fmt.Printf("Cache directory: %s\n", result.Root) + fmt.Println() + for _, ct := range cacheTypes { + fmt.Printf(" %-12s %s\n", ct.Name+":", result.Paths[ct.Name]) + } + return nil } diff --git a/cmd/cache/status.go b/cmd/cache/status.go index 74dbc2b..f985dfe 100644 --- a/cmd/cache/status.go +++ b/cmd/cache/status.go @@ -1,7 +1,17 @@ package cache import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/meigma/blob-cli/internal/archive" + internalcfg "github.com/meigma/blob-cli/internal/config" ) var statusCmd = &cobra.Command{ @@ -14,7 +24,101 @@ as the total cache size.`, Example: ` blob cache status blob cache status --output json`, Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: runStatus, +} + +// cacheStats holds statistics for a single cache type. +type cacheStats struct { + Name string `json:"name"` + Path string `json:"path"` + Enabled bool `json:"enabled"` + Size int64 `json:"size"` + SizeHuman string `json:"size_human"` + Files int `json:"files"` +} + +// statusResult contains the status output data. +type statusResult struct { + Root string `json:"root"` + Caches []cacheStats `json:"caches"` + TotalSize int64 `json:"total_size"` + TotalHuman string `json:"total_size_human"` + TotalFiles int `json:"total_files"` +} + +func runStatus(cmd *cobra.Command, _ []string) error { + cfg := internalcfg.FromContext(cmd.Context()) + if cfg == nil { + return errors.New("configuration not loaded") + } + + cacheDir, err := resolveCacheDir(cfg) + if err != nil { + return fmt.Errorf("determining cache directory: %w", err) + } + + result := statusResult{ + Root: cacheDir, + Caches: make([]cacheStats, 0, len(cacheTypes)), + } + + for _, ct := range cacheTypes { + path := filepath.Join(cacheDir, ct.SubDir) + enabled := isCacheTypeEnabled(cfg, ct.Name) + size := getDirSize(path) + files := countFiles(path) + + result.Caches = append(result.Caches, cacheStats{ + Name: ct.Name, + Path: path, + Enabled: enabled, + Size: size, + SizeHuman: archive.FormatSize(uint64(max(0, size))), //nolint:gosec // size is always non-negative + Files: files, + }) + result.TotalSize += size + result.TotalFiles += files + } + result.TotalHuman = archive.FormatSize(uint64(max(0, result.TotalSize))) //nolint:gosec // size is always non-negative + + if cfg.Quiet { return nil - }, + } + + if viper.GetString("output") == internalcfg.OutputJSON { + return statusJSON(&result) + } + return statusText(&result) +} + +func statusJSON(result *statusResult) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) +} + +func statusText(result *statusResult) error { + fmt.Printf("Cache directory: %s\n", result.Root) + fmt.Println() + + // Calculate max width for alignment + maxNameLen := 0 + for _, c := range result.Caches { + if len(c.Name) > maxNameLen { + maxNameLen = len(c.Name) + } + } + + for _, c := range result.Caches { + status := "" + if !c.Enabled { + status = " (disabled)" + } + fmt.Printf(" %-*s %8s %5d files%s\n", maxNameLen, c.Name, c.SizeHuman, c.Files, status) + } + + fmt.Println() + fmt.Printf("Total: %s (%d files)\n", result.TotalHuman, result.TotalFiles) + + return nil } diff --git a/cmd/cache/util.go b/cmd/cache/util.go new file mode 100644 index 0000000..b4679fd --- /dev/null +++ b/cmd/cache/util.go @@ -0,0 +1,125 @@ +package cache + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + + internalcfg "github.com/meigma/blob-cli/internal/config" +) + +// cacheTypeAll is the special cache type name for all caches. +const cacheTypeAll = "all" + +// cacheType describes a cache subdirectory. +type cacheType struct { + Name string // Display name + SubDir string // Subdirectory under cache root + Description string // Human-readable description +} + +// cacheTypes lists all cache types in display order. +var cacheTypes = []cacheType{ + {"content", "content", "File content cache"}, + {"blocks", "blocks", "HTTP range block cache"}, + {"refs", "refs", "Tag to digest mappings"}, + {"manifests", "manifests", "OCI manifest cache"}, + {"indexes", "indexes", "Archive index cache"}, +} + +// validCacheType returns true if the given type name is valid. +func validCacheType(name string) bool { + if name == cacheTypeAll { + return true + } + for _, ct := range cacheTypes { + if ct.Name == name { + return true + } + } + return false +} + +// cacheTypeNames returns a list of valid cache type names. +func cacheTypeNames() []string { + names := make([]string, len(cacheTypes)+1) + for i, ct := range cacheTypes { + names[i] = ct.Name + } + names[len(cacheTypes)] = cacheTypeAll + return names +} + +// resolveCacheDir returns the cache directory to use. +// Priority: config file > XDG default. +func resolveCacheDir(cfg *internalcfg.Config) (string, error) { + if cfg.Cache.Dir != "" { + return cfg.Cache.Dir, nil + } + return internalcfg.CacheDir() +} + +// getDirSize calculates the total size of all files in a directory recursively. +// Returns 0 if the directory doesn't exist. Warns to stderr on permission errors. +func getDirSize(dir string) int64 { + var size int64 + var hadError bool + //nolint:errcheck // Walk errors are handled by tracking hadError + filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + hadError = true + return fs.SkipDir // Skip inaccessible directories + } + if !d.IsDir() { + if info, infoErr := d.Info(); infoErr == nil { + size += info.Size() + } + } + return nil + }) + if hadError { + fmt.Fprintf(os.Stderr, "Warning: some files in %s could not be accessed; size may be incomplete\n", dir) + } + return size +} + +// countFiles counts all files in a directory recursively. +// Returns 0 if the directory doesn't exist. Warns to stderr on permission errors. +func countFiles(dir string) int { + var count int + var hadError bool + //nolint:errcheck // Walk errors are handled by tracking hadError + filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + hadError = true + return fs.SkipDir // Skip inaccessible directories + } + if !d.IsDir() { + count++ + } + return nil + }) + if hadError { + fmt.Fprintf(os.Stderr, "Warning: some files in %s could not be accessed; count may be incomplete\n", dir) + } + return count +} + +// isCacheTypeEnabled returns whether a cache type is enabled in the config. +func isCacheTypeEnabled(cfg *internalcfg.Config, name string) bool { + switch name { + case "content": + return cfg.Cache.ContentEnabled() + case "blocks": + return cfg.Cache.BlocksEnabled() + case "refs": + return cfg.Cache.RefsEnabled() + case "manifests": + return cfg.Cache.ManifestsEnabled() + case "indexes": + return cfg.Cache.IndexesEnabled() + default: + return false + } +} diff --git a/cmd/cache/util_test.go b/cmd/cache/util_test.go new file mode 100644 index 0000000..4a01635 --- /dev/null +++ b/cmd/cache/util_test.go @@ -0,0 +1,190 @@ +package cache + +import ( + "os" + "path/filepath" + "testing" +) + +func TestValidCacheType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + typeName string + want bool + }{ + {"all", "all", true}, + {"content", "content", true}, + {"blocks", "blocks", true}, + {"refs", "refs", true}, + {"manifests", "manifests", true}, + {"indexes", "indexes", true}, + {"invalid", "invalid", false}, + {"empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := validCacheType(tt.typeName) + if got != tt.want { + t.Errorf("validCacheType(%q) = %v, want %v", tt.typeName, got, tt.want) + } + }) + } +} + +func TestCacheTypeNames(t *testing.T) { + t.Parallel() + + names := cacheTypeNames() + + // Should contain all cache types plus "all" + expectedCount := len(cacheTypes) + 1 + if len(names) != expectedCount { + t.Errorf("cacheTypeNames() returned %d names, want %d", len(names), expectedCount) + } + + // Last element should be "all" + if names[len(names)-1] != cacheTypeAll { + t.Errorf("cacheTypeNames() last element = %q, want %q", names[len(names)-1], cacheTypeAll) + } + + // All individual cache types should be present + for _, ct := range cacheTypes { + found := false + for _, name := range names { + if name == ct.Name { + found = true + break + } + } + if !found { + t.Errorf("cacheTypeNames() missing cache type %q", ct.Name) + } + } +} + +func TestGetDirSize(t *testing.T) { + t.Parallel() + + t.Run("empty directory", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + size := getDirSize(dir) + if size != 0 { + t.Errorf("getDirSize(empty dir) = %d, want 0", size) + } + }) + + t.Run("directory with files", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create test files + content := []byte("test content") + if err := os.WriteFile(filepath.Join(dir, "file1.txt"), content, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "file2.txt"), content, 0o644); err != nil { + t.Fatal(err) + } + + size := getDirSize(dir) + expectedSize := int64(len(content) * 2) + if size != expectedSize { + t.Errorf("getDirSize() = %d, want %d", size, expectedSize) + } + }) + + t.Run("nested directories", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + subdir := filepath.Join(dir, "subdir") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + + content := []byte("nested file") + if err := os.WriteFile(filepath.Join(subdir, "nested.txt"), content, 0o644); err != nil { + t.Fatal(err) + } + + size := getDirSize(dir) + expectedSize := int64(len(content)) + if size != expectedSize { + t.Errorf("getDirSize() = %d, want %d", size, expectedSize) + } + }) + + t.Run("nonexistent directory", func(t *testing.T) { + t.Parallel() + size := getDirSize("/nonexistent/path/that/does/not/exist") + if size != 0 { + t.Errorf("getDirSize(nonexistent) = %d, want 0", size) + } + }) +} + +func TestCountFiles(t *testing.T) { + t.Parallel() + + t.Run("empty directory", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + count := countFiles(dir) + if count != 0 { + t.Errorf("countFiles(empty dir) = %d, want 0", count) + } + }) + + t.Run("directory with files", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create test files + for i := range 3 { + if err := os.WriteFile(filepath.Join(dir, "file"+string(rune('a'+i))), []byte("content"), 0o644); err != nil { + t.Fatal(err) + } + } + + count := countFiles(dir) + if count != 3 { + t.Errorf("countFiles() = %d, want 3", count) + } + }) + + t.Run("nested directories", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + // Create nested structure with files + subdir := filepath.Join(dir, "subdir") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(dir, "root.txt"), []byte("content"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subdir, "nested.txt"), []byte("content"), 0o644); err != nil { + t.Fatal(err) + } + + count := countFiles(dir) + if count != 2 { + t.Errorf("countFiles() = %d, want 2", count) + } + }) + + t.Run("nonexistent directory", func(t *testing.T) { + t.Parallel() + count := countFiles("/nonexistent/path/that/does/not/exist") + if count != 0 { + t.Errorf("countFiles(nonexistent) = %d, want 0", count) + } + }) +} diff --git a/cmd/cat.go b/cmd/cat.go index c47c70f..919698f 100644 --- a/cmd/cat.go +++ b/cmd/cat.go @@ -27,6 +27,10 @@ downloading the entire archive.`, RunE: runCat, } +func init() { + catCmd.Flags().Bool("skip-cache", false, "bypass registry caches for this operation") +} + func runCat(cmd *cobra.Command, args []string) error { // 1. Get config from context cfg := internalcfg.FromContext(cmd.Context()) @@ -38,23 +42,39 @@ func runCat(cmd *cobra.Command, args []string) error { inputRef := args[0] filePaths := args[1:] - // 3. Resolve alias + // 3. Parse flags + skipCache, flagErr := cmd.Flags().GetBool("skip-cache") + if flagErr != nil { + return fmt.Errorf("reading skip-cache flag: %w", flagErr) + } + + // 4. Resolve alias resolvedRef := cfg.ResolveAlias(inputRef) - // 4. Create client (lazy - only downloads manifest + index) - client, err := newClient(cfg) + // 5. Create client (lazy - only downloads manifest + index) + var client *blob.Client + var err error + if skipCache { + client, err = blob.NewClient(clientOptsNoCache(cfg)...) + } else { + client, err = newClient(cfg) + } if err != nil { return fmt.Errorf("creating client: %w", err) } - // 5. Pull archive (lazy - does NOT download data blob) + // 6. Pull archive (lazy - does NOT download data blob) ctx := cmd.Context() - blobArchive, err := client.Pull(ctx, resolvedRef) + var pullOpts []blob.PullOption + if skipCache { + pullOpts = append(pullOpts, blob.PullWithSkipCache()) + } + blobArchive, err := client.Pull(ctx, resolvedRef, pullOpts...) if err != nil { return fmt.Errorf("accessing archive %s: %w", resolvedRef, err) } - // 6. Validate all files exist and are not directories before outputting anything + // 7. Validate all files exist and are not directories before outputting anything normalizedPaths, err := blobArchive.ValidateFiles(filePaths...) if err != nil { var ve *blob.ValidationError @@ -71,12 +91,12 @@ func runCat(cmd *cobra.Command, args []string) error { return fmt.Errorf("validating files: %w", err) } - // 7. Check quiet mode - suppress output only after validation + // 8. Check quiet mode - suppress output only after validation if cfg.Quiet { return nil } - // 8. Stream each file to stdout + // 9. Stream each file to stdout for _, normalizedPath := range normalizedPaths { if err := catFile(blobArchive, normalizedPath); err != nil { return err diff --git a/cmd/client.go b/cmd/client.go index 1feff2f..d997d7e 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -1,6 +1,11 @@ package cmd import ( + "fmt" + "os" + "path/filepath" + "time" + "github.com/meigma/blob" internalcfg "github.com/meigma/blob-cli/internal/config" @@ -8,16 +13,80 @@ import ( // newClient creates a new blob client with options from config. func newClient(cfg *internalcfg.Config, opts ...blob.Option) (*blob.Client, error) { - allOpts := append(clientOpts(cfg), opts...) - return blob.NewClient(allOpts...) + baseOpts := clientOpts(cfg) + baseOpts = append(baseOpts, opts...) + return blob.NewClient(baseOpts...) } // clientOpts returns the base client options from config. // This is useful when passing options to functions that create their own client. +// If caching is enabled but the cache directory cannot be resolved, a warning +// is written to stderr and caching is disabled for this operation. func clientOpts(cfg *internalcfg.Config) []blob.Option { + opts := []blob.Option{blob.WithDockerConfig()} + if cfg.PlainHTTP { + opts = append(opts, blob.WithPlainHTTP(true)) + } + if cfg.Cache.Enabled { + cacheDir, err := resolveCacheDir(cfg) + if err != nil { + if !cfg.Quiet { + fmt.Fprintf(os.Stderr, "Warning: cache disabled: %v\n", err) + } + } else { + opts = append(opts, buildCacheOpts(cfg, cacheDir)...) + } + } + return opts +} + +// buildCacheOpts returns cache options based on config. +// Each cache type is enabled individually based on the config settings. +func buildCacheOpts(cfg *internalcfg.Config, cacheDir string) []blob.Option { + var opts []blob.Option + cache := &cfg.Cache + + if cache.ContentEnabled() { + opts = append(opts, blob.WithContentCacheDir(filepath.Join(cacheDir, "content"))) + } + if cache.BlocksEnabled() { + opts = append(opts, blob.WithBlockCacheDir(filepath.Join(cacheDir, "blocks"))) + } + if cache.RefsEnabled() { + opts = append(opts, blob.WithRefCacheDir(filepath.Join(cacheDir, "refs"))) + } + if cache.ManifestsEnabled() { + opts = append(opts, blob.WithManifestCacheDir(filepath.Join(cacheDir, "manifests"))) + } + if cache.IndexesEnabled() { + opts = append(opts, blob.WithIndexCacheDir(filepath.Join(cacheDir, "indexes"))) + } + + // Only set TTL if refs cache is enabled + if cache.RefsEnabled() && cache.RefTTL != "" { + if ttl, err := time.ParseDuration(cache.RefTTL); err == nil { + opts = append(opts, blob.WithRefCacheTTL(ttl)) + } + } + + return opts +} + +// clientOptsNoCache returns client options without caching. +// Use this when --skip-cache flag is set. +func clientOptsNoCache(cfg *internalcfg.Config) []blob.Option { opts := []blob.Option{blob.WithDockerConfig()} if cfg.PlainHTTP { opts = append(opts, blob.WithPlainHTTP(true)) } return opts } + +// resolveCacheDir returns the cache directory to use. +// Priority: config file > XDG default. +func resolveCacheDir(cfg *internalcfg.Config) (string, error) { + if cfg.Cache.Dir != "" { + return cfg.Cache.Dir, nil + } + return internalcfg.CacheDir() +} diff --git a/cmd/client_test.go b/cmd/client_test.go new file mode 100644 index 0000000..26184cf --- /dev/null +++ b/cmd/client_test.go @@ -0,0 +1,285 @@ +package cmd + +import ( + "os" + "testing" + + internalcfg "github.com/meigma/blob-cli/internal/config" +) + +func TestResolveCacheDir(t *testing.T) { + // Note: Not parallel because subtests use t.Setenv + + t.Run("uses config dir when specified", func(t *testing.T) { + cfg := &internalcfg.Config{ + Cache: internalcfg.CacheConfig{ + Enabled: true, + Dir: "/custom/cache/dir", + }, + } + + got, err := resolveCacheDir(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/custom/cache/dir" { + t.Errorf("resolveCacheDir() = %q, want %q", got, "/custom/cache/dir") + } + }) + + t.Run("uses XDG default when config dir empty", func(t *testing.T) { + // Note: Can't use t.Parallel() with t.Setenv() + + // Set up a predictable XDG_CACHE_HOME + tmpDir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", tmpDir) + + cfg := &internalcfg.Config{ + Cache: internalcfg.CacheConfig{ + Enabled: true, + Dir: "", + }, + } + + got, err := resolveCacheDir(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := tmpDir + "/blob" + if got != want { + t.Errorf("resolveCacheDir() = %q, want %q", got, want) + } + }) +} + +func TestClientOpts(t *testing.T) { + t.Parallel() + + t.Run("includes cache options when enabled", func(t *testing.T) { + t.Parallel() + + // Create temp dir for cache + tmpDir := t.TempDir() + + cfg := &internalcfg.Config{ + Cache: internalcfg.CacheConfig{ + Enabled: true, + Dir: tmpDir, + }, + } + + opts := clientOpts(cfg) + + // Should have at least 2 options: WithDockerConfig and WithCacheDir + if len(opts) < 2 { + t.Errorf("clientOpts() returned %d options, want at least 2", len(opts)) + } + }) + + t.Run("excludes cache options when disabled", func(t *testing.T) { + t.Parallel() + + cfg := &internalcfg.Config{ + Cache: internalcfg.CacheConfig{ + Enabled: false, + }, + } + + opts := clientOpts(cfg) + + // Should have only 1 option: WithDockerConfig + if len(opts) != 1 { + t.Errorf("clientOpts() returned %d options, want 1", len(opts)) + } + }) + + t.Run("includes PlainHTTP when enabled", func(t *testing.T) { + t.Parallel() + + cfg := &internalcfg.Config{ + PlainHTTP: true, + Cache: internalcfg.CacheConfig{ + Enabled: false, + }, + } + + opts := clientOpts(cfg) + + // Should have 2 options: WithDockerConfig and WithPlainHTTP + if len(opts) != 2 { + t.Errorf("clientOpts() returned %d options, want 2", len(opts)) + } + }) +} + +func TestClientOptsNoCache(t *testing.T) { + t.Parallel() + + t.Run("never includes cache options", func(t *testing.T) { + t.Parallel() + + cfg := &internalcfg.Config{ + Cache: internalcfg.CacheConfig{ + Enabled: true, + Dir: "/some/cache/dir", + }, + } + + opts := clientOptsNoCache(cfg) + + // Should have only 1 option: WithDockerConfig + if len(opts) != 1 { + t.Errorf("clientOptsNoCache() returned %d options, want 1", len(opts)) + } + }) + + t.Run("includes PlainHTTP when enabled", func(t *testing.T) { + t.Parallel() + + cfg := &internalcfg.Config{ + PlainHTTP: true, + Cache: internalcfg.CacheConfig{ + Enabled: true, + Dir: "/some/cache/dir", + }, + } + + opts := clientOptsNoCache(cfg) + + // Should have 2 options: WithDockerConfig and WithPlainHTTP + if len(opts) != 2 { + t.Errorf("clientOptsNoCache() returned %d options, want 2", len(opts)) + } + }) +} + +func ptr[T any](v T) *T { + return &v +} + +func TestBuildCacheOpts(t *testing.T) { + t.Parallel() + + t.Run("all caches enabled by default", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + cfg := &internalcfg.Config{ + Cache: internalcfg.CacheConfig{ + Enabled: true, + }, + } + + opts := buildCacheOpts(cfg, tmpDir) + + // Should have 5 options: one for each cache type + if len(opts) != 5 { + t.Errorf("buildCacheOpts() returned %d options, want 5", len(opts)) + } + }) + + t.Run("disabling individual cache reduces options", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + cfg := &internalcfg.Config{ + Cache: internalcfg.CacheConfig{ + Enabled: true, + Refs: &internalcfg.IndividualCacheConfig{Enabled: ptr(false)}, + }, + } + + opts := buildCacheOpts(cfg, tmpDir) + + // Should have 4 options: all except refs + if len(opts) != 4 { + t.Errorf("buildCacheOpts() returned %d options, want 4", len(opts)) + } + }) + + t.Run("disabling multiple caches", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + cfg := &internalcfg.Config{ + Cache: internalcfg.CacheConfig{ + Enabled: true, + Content: &internalcfg.IndividualCacheConfig{Enabled: ptr(false)}, + Blocks: &internalcfg.IndividualCacheConfig{Enabled: ptr(false)}, + Refs: &internalcfg.IndividualCacheConfig{Enabled: ptr(false)}, + Manifests: &internalcfg.IndividualCacheConfig{Enabled: ptr(false)}, + }, + } + + opts := buildCacheOpts(cfg, tmpDir) + + // Should have 1 option: only indexes + if len(opts) != 1 { + t.Errorf("buildCacheOpts() returned %d options, want 1", len(opts)) + } + }) + + t.Run("ref_ttl adds option when refs enabled", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + cfg := &internalcfg.Config{ + Cache: internalcfg.CacheConfig{ + Enabled: true, + RefTTL: "10m", + }, + } + + opts := buildCacheOpts(cfg, tmpDir) + + // Should have 6 options: 5 caches + 1 TTL + if len(opts) != 6 { + t.Errorf("buildCacheOpts() returned %d options, want 6", len(opts)) + } + }) + + t.Run("ref_ttl ignored when refs disabled", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + cfg := &internalcfg.Config{ + Cache: internalcfg.CacheConfig{ + Enabled: true, + RefTTL: "10m", + Refs: &internalcfg.IndividualCacheConfig{Enabled: ptr(false)}, + }, + } + + opts := buildCacheOpts(cfg, tmpDir) + + // Should have 4 options: 4 caches (no refs), no TTL + if len(opts) != 4 { + t.Errorf("buildCacheOpts() returned %d options, want 4", len(opts)) + } + }) + + t.Run("invalid ref_ttl is ignored", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + cfg := &internalcfg.Config{ + Cache: internalcfg.CacheConfig{ + Enabled: true, + RefTTL: "invalid", + }, + } + + opts := buildCacheOpts(cfg, tmpDir) + + // Should have 5 options: invalid TTL is skipped + if len(opts) != 5 { + t.Errorf("buildCacheOpts() returned %d options, want 5", len(opts)) + } + }) +} + +func TestMain(m *testing.M) { + // Ensure tests don't accidentally use real config + os.Exit(m.Run()) +} diff --git a/cmd/config/show.go b/cmd/config/show.go index b85f9af..6435a92 100644 --- a/cmd/config/show.go +++ b/cmd/config/show.go @@ -60,7 +60,28 @@ func showText(cfg *internalcfg.Config) error { fmt.Println() fmt.Println("cache:") fmt.Printf(" enabled: %t\n", cfg.Cache.Enabled) - fmt.Printf(" max_size: %s\n", cfg.Cache.MaxSize) + if cfg.Cache.Dir != "" { + fmt.Printf(" dir: %s\n", cfg.Cache.Dir) + } + if cfg.Cache.RefTTL != "" { + fmt.Printf(" ref_ttl: %s\n", cfg.Cache.RefTTL) + } + if cfg.Cache.MaxSize != "" { + fmt.Printf(" max_size: %s (deprecated)\n", cfg.Cache.MaxSize) + } + + // Per-cache settings (only show if explicitly configured) + showCacheType := func(name string, individual *internalcfg.IndividualCacheConfig, enabled bool) { + if individual != nil && individual.Enabled != nil { + fmt.Printf(" %s:\n", name) + fmt.Printf(" enabled: %t\n", enabled) + } + } + showCacheType("content", cfg.Cache.Content, cfg.Cache.ContentEnabled()) + showCacheType("blocks", cfg.Cache.Blocks, cfg.Cache.BlocksEnabled()) + showCacheType("refs", cfg.Cache.Refs, cfg.Cache.RefsEnabled()) + showCacheType("manifests", cfg.Cache.Manifests, cfg.Cache.ManifestsEnabled()) + showCacheType("indexes", cfg.Cache.Indexes, cfg.Cache.IndexesEnabled()) // Aliases (sorted for deterministic output) fmt.Println() diff --git a/cmd/cp.go b/cmd/cp.go index 5ac8b86..cc6e678 100644 --- a/cmd/cp.go +++ b/cmd/cp.go @@ -41,6 +41,7 @@ func init() { cpCmd.Flags().BoolP("recursive", "r", true, "copy directories recursively") cpCmd.Flags().Bool("preserve", false, "preserve file permissions and timestamps from archive") cpCmd.Flags().BoolP("force", "f", false, "overwrite existing files") + cpCmd.Flags().Bool("skip-cache", false, "bypass registry caches for this operation") } // cpFlags holds the parsed command flags. @@ -48,6 +49,7 @@ type cpFlags struct { recursive bool preserve bool force bool + skipCache bool } // cpSource represents a parsed source argument (ref:/path). @@ -107,7 +109,7 @@ func runCp(cmd *cobra.Command, args []string) error { resolvedSources := make([]cpResolvedSource, 0, len(sources)) for _, src := range sources { - rsrc, resolveErr := resolveSource(ctx, cfg, src, archiveCache) + rsrc, resolveErr := resolveSource(ctx, cfg, src, archiveCache, flags.skipCache) if resolveErr != nil { return resolveErr } @@ -148,16 +150,26 @@ func runCp(cmd *cobra.Command, args []string) error { } // resolveSource pulls the archive (if not cached) and detects if the source is a file or directory. -func resolveSource(ctx context.Context, cfg *internalcfg.Config, src cpSource, cache map[string]*blob.Archive) (cpResolvedSource, error) { +func resolveSource(ctx context.Context, cfg *internalcfg.Config, src cpSource, cache map[string]*blob.Archive, skipCache bool) (cpResolvedSource, error) { // Get or create archive for this ref blobArchive, ok := cache[src.ref] if !ok { - client, clientErr := newClient(cfg) + var client *blob.Client + var clientErr error + if skipCache { + client, clientErr = blob.NewClient(clientOptsNoCache(cfg)...) + } else { + client, clientErr = newClient(cfg) + } if clientErr != nil { return cpResolvedSource{}, fmt.Errorf("creating client: %w", clientErr) } + var pullOpts []blob.PullOption + if skipCache { + pullOpts = append(pullOpts, blob.PullWithSkipCache()) + } var pullErr error - blobArchive, pullErr = client.Pull(ctx, src.ref) + blobArchive, pullErr = client.Pull(ctx, src.ref, pullOpts...) if pullErr != nil { return cpResolvedSource{}, fmt.Errorf("accessing archive %s: %w", src.ref, pullErr) } @@ -398,6 +410,11 @@ func parseCpFlags(cmd *cobra.Command) (cpFlags, error) { return flags, fmt.Errorf("reading force flag: %w", err) } + flags.skipCache, err = cmd.Flags().GetBool("skip-cache") + if err != nil { + return flags, fmt.Errorf("reading skip-cache flag: %w", err) + } + return flags, nil } diff --git a/cmd/inspect.go b/cmd/inspect.go index aa2fada..fcc0027 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -41,6 +41,10 @@ Displays information including: RunE: runInspect, } +func init() { + inspectCmd.Flags().Bool("skip-cache", false, "bypass registry caches for this operation") +} + // inspectOutput contains the inspect output data for JSON format. type inspectOutput struct { Ref string `json:"ref"` @@ -77,8 +81,20 @@ func runInspect(cmd *cobra.Command, args []string) error { inputRef := args[0] resolvedRef := cfg.ResolveAlias(inputRef) + skipCache, err := cmd.Flags().GetBool("skip-cache") + if err != nil { + return fmt.Errorf("reading skip-cache flag: %w", err) + } + + var opts archive.InspectOptions + if skipCache { + opts.ClientOpts = clientOptsNoCache(cfg) + opts.InspectOpts = []blob.InspectOption{blob.InspectWithSkipCache()} + } else { + opts.ClientOpts = clientOpts(cfg) + } - result, err := archive.Inspect(cmd.Context(), resolvedRef, clientOpts(cfg)...) + result, err := archive.InspectWithOptions(cmd.Context(), resolvedRef, opts) if err != nil { return err } diff --git a/cmd/ls.go b/cmd/ls.go index 388ed12..ad1df3e 100644 --- a/cmd/ls.go +++ b/cmd/ls.go @@ -8,6 +8,7 @@ import ( "strconv" "time" + "github.com/meigma/blob" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -35,13 +36,15 @@ func init() { lsCmd.Flags().BoolP("human", "h", false, "human-readable sizes (use with -l)") lsCmd.Flags().BoolP("long", "l", false, "long format (permissions, size, hash)") lsCmd.Flags().Bool("digest", false, "show file digests") + lsCmd.Flags().Bool("skip-cache", false, "bypass registry caches for this operation") } // lsFlags holds the parsed command flags. type lsFlags struct { - long bool - human bool - digest bool + long bool + human bool + digest bool + skipCache bool } // lsResult contains the ls output data for JSON format. @@ -80,7 +83,15 @@ func runLs(cmd *cobra.Command, args []string) error { return err } - result, err := archive.Inspect(cmd.Context(), ref, clientOpts(cfg)...) + var opts archive.InspectOptions + if flags.skipCache { + opts.ClientOpts = clientOptsNoCache(cfg) + opts.InspectOpts = []blob.InspectOption{blob.InspectWithSkipCache()} + } else { + opts.ClientOpts = clientOpts(cfg) + } + + result, err := archive.InspectWithOptions(cmd.Context(), ref, opts) if err != nil { return err } @@ -119,6 +130,11 @@ func parseLsFlags(cmd *cobra.Command) (lsFlags, error) { return flags, fmt.Errorf("reading digest flag: %w", err) } + flags.skipCache, err = cmd.Flags().GetBool("skip-cache") + if err != nil { + return flags, fmt.Errorf("reading skip-cache flag: %w", err) + } + return flags, nil } diff --git a/cmd/pull.go b/cmd/pull.go index bba7c06..50c62d2 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -38,6 +38,7 @@ func init() { pullCmd.Flags().StringArray("policy", nil, "policy file for verification (repeatable)") pullCmd.Flags().String("policy-rego", "", "OPA Rego policy file") pullCmd.Flags().Bool("no-default-policy", false, "skip policies from config file") + pullCmd.Flags().Bool("skip-cache", false, "bypass registry caches for this operation") } // pullResult contains the result of a pull operation. @@ -57,6 +58,7 @@ type pullFlags struct { policyFiles []string policyRego string noDefaultPolicy bool + skipCache bool } func runPull(cmd *cobra.Command, args []string) error { @@ -99,14 +101,26 @@ func runPull(cmd *cobra.Command, args []string) error { for _, p := range policies { policyOpts = append(policyOpts, blob.WithPolicy(p)) } - client, err := newClient(cfg, policyOpts...) + + var client *blob.Client + if flags.skipCache { + // Use no-cache client options + allOpts := append(clientOptsNoCache(cfg), policyOpts...) + client, err = blob.NewClient(allOpts...) + } else { + client, err = newClient(cfg, policyOpts...) + } if err != nil { return fmt.Errorf("creating client: %w", err) } // 7. Pull archive (policy verification happens here) ctx := cmd.Context() - blobArchive, err := client.Pull(ctx, resolvedRef) + var pullOpts []blob.PullOption + if flags.skipCache { + pullOpts = append(pullOpts, blob.PullWithSkipCache()) + } + blobArchive, err := client.Pull(ctx, resolvedRef, pullOpts...) if err != nil { if errors.Is(err, blob.ErrPolicyViolation) { return fmt.Errorf("verification failed: %w", err) @@ -174,6 +188,11 @@ func parsePullFlags(cmd *cobra.Command) (pullFlags, error) { return flags, fmt.Errorf("reading no-default-policy flag: %w", err) } + flags.skipCache, err = cmd.Flags().GetBool("skip-cache") + if err != nil { + return flags, fmt.Errorf("reading skip-cache flag: %w", err) + } + return flags, nil } diff --git a/cmd/root.go b/cmd/root.go index 22c9f48..75fa5c2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -118,6 +118,9 @@ func initConfig() { viper.SetEnvPrefix("BLOB") viper.AutomaticEnv() + // Bind cache.dir to BLOB_CACHE_DIR explicitly for nested key + viper.BindEnv("cache.dir", "BLOB_CACHE_DIR") //nolint:errcheck // best effort + // Config file is optional - don't fail if missing viper.ReadInConfig() //nolint:errcheck // config file is optional } diff --git a/cmd/tree.go b/cmd/tree.go index 1244c98..1946218 100644 --- a/cmd/tree.go +++ b/cmd/tree.go @@ -6,6 +6,7 @@ import ( "fmt" "os" + "github.com/meigma/blob" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -29,12 +30,14 @@ archive, similar to the tree command.`, func init() { treeCmd.Flags().IntP("level", "L", 0, "descend only n levels deep (0 = unlimited)") treeCmd.Flags().Bool("dirsfirst", false, "list directories before files") + treeCmd.Flags().Bool("skip-cache", false, "bypass registry caches for this operation") } // treeFlags holds the parsed command flags. type treeFlags struct { level int dirsFirst bool + skipCache bool } // treeResult contains the tree output data for JSON format. @@ -71,7 +74,15 @@ func runTree(cmd *cobra.Command, args []string) error { return err } - result, err := archive.Inspect(cmd.Context(), ref, clientOpts(cfg)...) + var opts archive.InspectOptions + if flags.skipCache { + opts.ClientOpts = clientOptsNoCache(cfg) + opts.InspectOpts = []blob.InspectOption{blob.InspectWithSkipCache()} + } else { + opts.ClientOpts = clientOpts(cfg) + } + + result, err := archive.InspectWithOptions(cmd.Context(), ref, opts) if err != nil { return err } @@ -105,6 +116,11 @@ func parseTreeFlags(cmd *cobra.Command) (treeFlags, error) { return flags, fmt.Errorf("reading dirsfirst flag: %w", err) } + flags.skipCache, err = cmd.Flags().GetBool("skip-cache") + if err != nil { + return flags, fmt.Errorf("reading skip-cache flag: %w", err) + } + return flags, nil } diff --git a/cmd/verify.go b/cmd/verify.go index 55c75eb..7677543 100644 --- a/cmd/verify.go +++ b/cmd/verify.go @@ -44,6 +44,7 @@ func init() { verifyCmd.Flags().StringArray("policy", nil, "policy file for verification (repeatable)") verifyCmd.Flags().String("policy-rego", "", "OPA Rego policy file") verifyCmd.Flags().Bool("no-default-policy", false, "skip policies from config file") + verifyCmd.Flags().Bool("skip-cache", false, "bypass registry caches for this operation") } // verifyResult contains the result of a verify operation. @@ -63,6 +64,7 @@ type verifyFlags struct { policyFiles []string policyRego string noDefaultPolicy bool + skipCache bool } func runVerify(cmd *cobra.Command, args []string) error { @@ -107,25 +109,7 @@ func runVerify(cmd *cobra.Command, args []string) error { // 7. Handle no-policies case if len(policies) == 0 { - // No policies - vacuous success with warning - inspectResult, inspectErr := archive.Inspect(cmd.Context(), resolvedRef, clientOpts(cfg)...) - if inspectErr != nil { - return fmt.Errorf("inspecting archive: %w", inspectErr) - } - - result.Digest = inspectResult.Digest() - result.Verified = false - result.Status = "no_policies" - - // Fetch referrers for signatures/attestations - populateReferrers(cmd.Context(), inspectResult, &result) - - // Output warning and result - if !cfg.Quiet && viper.GetString("output") != internalcfg.OutputJSON { - fmt.Fprintln(os.Stderr, "Warning: No policies applied - archive not verified") - } - - return outputVerifyResult(cfg, &result) + return handleNoPolicies(cmd, cfg, resolvedRef, &result, flags.skipCache) } // 8. Create client with policies for verification @@ -133,14 +117,25 @@ func runVerify(cmd *cobra.Command, args []string) error { for _, p := range policies { policyOpts = append(policyOpts, blob.WithPolicy(p)) } - client, err := newClient(cfg, policyOpts...) + + var client *blob.Client + if flags.skipCache { + allOpts := append(clientOptsNoCache(cfg), policyOpts...) + client, err = blob.NewClient(allOpts...) + } else { + client, err = newClient(cfg, policyOpts...) + } if err != nil { return fmt.Errorf("creating client: %w", err) } // 9. Verify by calling Inspect (which triggers policy evaluation) ctx := cmd.Context() - inspectResult, err := client.Inspect(ctx, resolvedRef) + var inspectOpts []blob.InspectOption + if flags.skipCache { + inspectOpts = append(inspectOpts, blob.InspectWithSkipCache()) + } + inspectResult, err := client.Inspect(ctx, resolvedRef, inspectOpts...) if err != nil { if errors.Is(err, blob.ErrPolicyViolation) { return &ExitError{ @@ -182,9 +177,42 @@ func parseVerifyFlags(cmd *cobra.Command) (verifyFlags, error) { return flags, fmt.Errorf("reading no-default-policy flag: %w", err) } + flags.skipCache, err = cmd.Flags().GetBool("skip-cache") + if err != nil { + return flags, fmt.Errorf("reading skip-cache flag: %w", err) + } + return flags, nil } +// handleNoPolicies handles the case where no policies are specified. +func handleNoPolicies(cmd *cobra.Command, cfg *internalcfg.Config, resolvedRef string, result *verifyResult, skipCache bool) error { + var opts archive.InspectOptions + if skipCache { + opts.ClientOpts = clientOptsNoCache(cfg) + opts.InspectOpts = []blob.InspectOption{blob.InspectWithSkipCache()} + } else { + opts.ClientOpts = clientOpts(cfg) + } + + inspectResult, err := archive.InspectWithOptions(cmd.Context(), resolvedRef, opts) + if err != nil { + return fmt.Errorf("inspecting archive: %w", err) + } + + result.Digest = inspectResult.Digest() + result.Verified = false + result.Status = "no_policies" + + populateReferrers(cmd.Context(), inspectResult, result) + + if !cfg.Quiet && viper.GetString("output") != internalcfg.OutputJSON { + fmt.Fprintln(os.Stderr, "Warning: No policies applied - archive not verified") + } + + return outputVerifyResult(cfg, result) +} + // populateReferrers fetches signatures and attestations and adds them to the result. func populateReferrers(ctx context.Context, inspectResult *blob.InspectResult, result *verifyResult) { signatures, sigErr := inspectResult.Referrers(ctx, sigstoreArtifactType) diff --git a/integration/testdata/scripts/cache_integration.txtar b/integration/testdata/scripts/cache_integration.txtar new file mode 100644 index 0000000..cf15f37 --- /dev/null +++ b/integration/testdata/scripts/cache_integration.txtar @@ -0,0 +1,248 @@ +# Comprehensive cache integration tests +# Tests cache path, status, and clear commands with various scenarios + +# ============================================================================== +# SECTION 1: Initial State - Cache directories don't exist +# ============================================================================== + +# Verify cache status shows 0 for everything initially +exec blob cache status +stdout 'Cache directory:' +stdout '0 files' +stdout 'Total: 0 \(0 files\)' + +# Verify cache path shows correct XDG-based paths +exec blob cache path +stdout 'Cache directory:' +stdout '\.cache/blob' +stdout 'content:' +stdout 'blocks:' +stdout 'refs:' +stdout 'manifests:' +stdout 'indexes:' + +# Verify JSON output structure for cache path +exec blob cache path --output json +stdout '"root":' +stdout '"paths":' +stdout '"content":' +stdout '"blocks":' +stdout '"refs":' +stdout '"manifests":' +stdout '"indexes":' + +# Verify JSON output structure for cache status (initial state) +exec blob cache status --output json +stdout '"root":' +stdout '"caches":' +stdout '"total_size": 0' +stdout '"total_files": 0' + +# ============================================================================== +# SECTION 2: Cache Population via Operations +# ============================================================================== + +# Push an archive to populate some caches +gentag TAG1 +exec blob --plain-http push $REGISTRY/cache-test:$TAG1 sample-project +stdout 'Pushed' + +# Pull the archive to populate more caches (content, indexes) +mkdir output1 +exec blob --plain-http pull $REGISTRY/cache-test:$TAG1 output1 +stdout 'Pulled' + +# Verify cache status now shows non-zero values +exec blob cache status +stdout 'Cache directory:' +# At least one cache should have files now +! stdout 'Total: 0 \(0 files\)' + +# Verify JSON output shows non-zero totals (files > 0) +exec blob cache status --output json +stdout '"caches":' +stdout '"enabled": true' +stdout '"total_files": [1-9]' + +# ============================================================================== +# SECTION 3: Cache Clear - Specific Type +# ============================================================================== + +# Clear only the indexes cache +exec blob cache clear indexes --force +stdout 'Cleared' +stdout 'indexes' + +# Verify status still shows structure +exec blob cache status +stdout 'indexes' + +# Status JSON should still show structure +exec blob cache status --output json +stdout '"name": "indexes"' + +# ============================================================================== +# SECTION 4: Cache Clear - All Caches +# ============================================================================== + +# Re-populate caches by doing another pull +gentag TAG2 +exec blob --plain-http push $REGISTRY/cache-test2:$TAG2 sample-project +mkdir output2 +exec blob --plain-http pull $REGISTRY/cache-test2:$TAG2 output2 + +# Clear all caches +exec blob cache clear --force +stdout 'Cleared' + +# Verify all caches are empty +exec blob cache status +stdout 'Total: 0 \(0 files\)' + +# JSON format should also show zeros +exec blob cache status --output json +stdout '"total_size": 0' +stdout '"total_files": 0' + +# ============================================================================== +# SECTION 5: Cache Clear - Requires --force for JSON output +# ============================================================================== + +# Repopulate caches +gentag TAG3 +exec blob --plain-http push $REGISTRY/cache-test3:$TAG3 sample-project +mkdir output3 +exec blob --plain-http pull $REGISTRY/cache-test3:$TAG3 output3 + +# Clear with JSON output requires --force flag +! exec blob cache clear --output json +stderr '--force required' + +# With --force it works +exec blob cache clear --force --output json +stdout '"cleared":' +stdout '"total_size_cleared":' +stdout '"total_files_cleared":' + +# ============================================================================== +# SECTION 6: Per-Cache Enable/Disable via Config +# ============================================================================== + +# Clear all caches first to start fresh +exec blob cache clear --force + +# Create config directory and file that disables the indexes cache +mkdir $WORK/.config/blob +cp disabled-indexes-config.yaml $WORK/.config/blob/config.yaml + +# Verify status shows indexes as disabled +exec blob cache status +stdout 'indexes' +stdout '(disabled)' + +# JSON output should show enabled: false for the indexes entry specifically +exec blob cache status --output json +stdout '(?s)"name": "indexes".*"enabled": false' + +# Push and pull - indexes cache should not be populated (but others should be) +gentag TAG4 +exec blob --plain-http push $REGISTRY/cache-disabled:$TAG4 sample-project +mkdir output4 +exec blob --plain-http pull $REGISTRY/cache-disabled:$TAG4 output4 + +# Status still shows disabled indicator +exec blob cache status +stdout '(disabled)' + +# Verify in JSON: indexes is disabled and stays empty, while other caches are populated +exec blob cache status --output json +# indexes entry should be disabled and have 0 files +stdout '(?s)"name": "indexes".*"enabled": false' +stdout '(?s)"name": "indexes".*"files": 0' +# Total files should be > 0 (other caches populated) +stdout '"total_files": [1-9]' + +# ============================================================================== +# SECTION 7: Custom Cache Directory via Config +# ============================================================================== + +# Create config with custom cache directory +cp custom-cache-dir-config.yaml $WORK/.config/blob/config.yaml + +# Verify cache path uses custom directory +exec blob cache path +stdout 'custom-cache-location' + +# Verify JSON output also shows custom path +exec blob cache path --output json +stdout 'custom-cache-location' + +# Push and pull should use custom cache location +gentag TAG5 +exec blob --plain-http push $REGISTRY/cache-custom:$TAG5 sample-project +mkdir output5 +exec blob --plain-http pull $REGISTRY/cache-custom:$TAG5 output5 + +# Verify files exist in custom location +exists $WORK/custom-cache-location + +# Status should show files in custom location +exec blob cache status +stdout 'custom-cache-location' + +# ============================================================================== +# SECTION 8: Clear Invalid Cache Type +# ============================================================================== + +! exec blob cache clear invalidtype --force +stderr 'invalid cache type' + +# ============================================================================== +# SECTION 9: Clear Individual Cache Types +# ============================================================================== + +# Reset to default config +cp default-config.yaml $WORK/.config/blob/config.yaml + +# Populate caches +gentag TAG6 +exec blob --plain-http push $REGISTRY/cache-individual:$TAG6 sample-project +mkdir output6 +exec blob --plain-http pull $REGISTRY/cache-individual:$TAG6 output6 + +# Clear content cache only +exec blob cache clear content --force +stdout 'content' + +# Clear manifests cache only +exec blob cache clear manifests --force +stdout 'manifests' + +# Clear refs cache only +exec blob cache clear refs --force +stdout 'refs' + +# Clear blocks cache only +exec blob cache clear blocks --force +stdout 'blocks' + +-- disabled-indexes-config.yaml -- +output: text +compression: zstd +cache: + enabled: true + indexes: + enabled: false + +-- custom-cache-dir-config.yaml -- +output: text +compression: zstd +cache: + enabled: true + dir: custom-cache-location + +-- default-config.yaml -- +output: text +compression: zstd +cache: + enabled: true diff --git a/internal/archive/archive.go b/internal/archive/archive.go index 347e130..6c34f38 100644 --- a/internal/archive/archive.go +++ b/internal/archive/archive.go @@ -30,16 +30,27 @@ type DirEntry struct { Children []*DirEntry } +// InspectOptions holds options for the Inspect function. +type InspectOptions struct { + ClientOpts []blob.Option + InspectOpts []blob.InspectOption +} + // Inspect fetches archive metadata from a registry without downloading file data. // Additional client options can be passed to customize the client behavior. func Inspect(ctx context.Context, ref string, opts ...blob.Option) (*blob.InspectResult, error) { - clientOpts := append([]blob.Option{blob.WithDockerConfig()}, opts...) + return InspectWithOptions(ctx, ref, InspectOptions{ClientOpts: opts}) +} + +// InspectWithOptions fetches archive metadata with full control over client and inspect options. +func InspectWithOptions(ctx context.Context, ref string, opts InspectOptions) (*blob.InspectResult, error) { + clientOpts := append([]blob.Option{blob.WithDockerConfig()}, opts.ClientOpts...) client, err := blob.NewClient(clientOpts...) if err != nil { return nil, fmt.Errorf("creating client: %w", err) } - result, err := client.Inspect(ctx, ref) + result, err := client.Inspect(ctx, ref, opts.InspectOpts...) if err != nil { return nil, fmt.Errorf("inspecting archive %s: %w", ref, err) } diff --git a/internal/config/config.go b/internal/config/config.go index 475e527..49d6a3c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -77,7 +77,21 @@ compression: zstd # Cache settings cache: enabled: true - max_size: 5GB + # ref_ttl: 5m # TTL for tag-to-digest cache entries (default: 5m) + + # Per-cache configuration (optional) + # When cache.enabled is true, all caches are enabled by default. + # Uncomment to disable specific caches: + # content: + # enabled: false + # blocks: + # enabled: false + # refs: + # enabled: false + # manifests: + # enabled: false + # indexes: + # enabled: false # Aliases for frequently used references # Usage: blob pull foo:v1 → ghcr.io/acme/repo/foo:v1 diff --git a/internal/config/defaults.go b/internal/config/defaults.go index da72530..a68c6c3 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -43,4 +43,5 @@ func SetDefaults(v *viper.Viper) { v.SetDefault("compression", CompressionZstd) v.SetDefault("cache.enabled", true) v.SetDefault("cache.max_size", "5GB") + v.SetDefault("cache.ref_ttl", "5m") } diff --git a/internal/config/types.go b/internal/config/types.go index 6c6cc1e..35f1d83 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -32,11 +32,90 @@ type Config struct { // CacheConfig holds cache-related settings. type CacheConfig struct { - // Enabled controls whether caching is active. + // Enabled controls whether caching is active globally. Enabled bool `mapstructure:"enabled" json:"enabled"` - // MaxSize is the maximum cache size (e.g., "5GB", "500MB"). - MaxSize string `mapstructure:"max_size" json:"max_size"` + // MaxSize is deprecated. Use per-cache settings instead. + // Kept for backward compatibility. + MaxSize string `mapstructure:"max_size" json:"max_size,omitempty"` + + // Dir overrides the cache directory path. + // If empty, uses XDG Base Directory Specification ($XDG_CACHE_HOME/blob or ~/.cache/blob). + Dir string `mapstructure:"dir" json:"dir,omitempty"` + + // RefTTL sets the TTL for reference cache entries (e.g., "5m", "1h"). + // Default: 5 minutes. + RefTTL string `mapstructure:"ref_ttl" json:"ref_ttl,omitempty"` + + // Per-cache configuration (optional). + // When nil, inherits from top-level Enabled. + Content *IndividualCacheConfig `mapstructure:"content" json:"content,omitempty"` + Blocks *IndividualCacheConfig `mapstructure:"blocks" json:"blocks,omitempty"` + Refs *IndividualCacheConfig `mapstructure:"refs" json:"refs,omitempty"` + Manifests *IndividualCacheConfig `mapstructure:"manifests" json:"manifests,omitempty"` + Indexes *IndividualCacheConfig `mapstructure:"indexes" json:"indexes,omitempty"` +} + +// IndividualCacheConfig holds settings for a single cache type. +type IndividualCacheConfig struct { + // Enabled controls whether this cache type is active. + // If nil when parent cache.enabled is true, defaults to true. + Enabled *bool `mapstructure:"enabled" json:"enabled,omitempty"` +} + +// ContentEnabled returns whether the content cache is enabled. +func (c *CacheConfig) ContentEnabled() bool { + if !c.Enabled { + return false + } + if c.Content == nil || c.Content.Enabled == nil { + return true + } + return *c.Content.Enabled +} + +// BlocksEnabled returns whether the blocks cache is enabled. +func (c *CacheConfig) BlocksEnabled() bool { + if !c.Enabled { + return false + } + if c.Blocks == nil || c.Blocks.Enabled == nil { + return true + } + return *c.Blocks.Enabled +} + +// RefsEnabled returns whether the refs cache is enabled. +func (c *CacheConfig) RefsEnabled() bool { + if !c.Enabled { + return false + } + if c.Refs == nil || c.Refs.Enabled == nil { + return true + } + return *c.Refs.Enabled +} + +// ManifestsEnabled returns whether the manifests cache is enabled. +func (c *CacheConfig) ManifestsEnabled() bool { + if !c.Enabled { + return false + } + if c.Manifests == nil || c.Manifests.Enabled == nil { + return true + } + return *c.Manifests.Enabled +} + +// IndexesEnabled returns whether the indexes cache is enabled. +func (c *CacheConfig) IndexesEnabled() bool { + if !c.Enabled { + return false + } + if c.Indexes == nil || c.Indexes.Enabled == nil { + return true + } + return *c.Indexes.Enabled } // PolicyRule maps a reference pattern to verification policies. diff --git a/internal/config/types_test.go b/internal/config/types_test.go new file mode 100644 index 0000000..4876b87 --- /dev/null +++ b/internal/config/types_test.go @@ -0,0 +1,196 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func ptr[T any](v T) *T { + return &v +} + +func TestCacheConfig_ContentEnabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config CacheConfig + want bool + }{ + { + name: "global disabled", + config: CacheConfig{Enabled: false}, + want: false, + }, + { + name: "global enabled, no per-cache config", + config: CacheConfig{Enabled: true}, + want: true, + }, + { + name: "global enabled, per-cache nil enabled", + config: CacheConfig{Enabled: true, Content: &IndividualCacheConfig{}}, + want: true, + }, + { + name: "global enabled, per-cache explicitly enabled", + config: CacheConfig{Enabled: true, Content: &IndividualCacheConfig{Enabled: ptr(true)}}, + want: true, + }, + { + name: "global enabled, per-cache explicitly disabled", + config: CacheConfig{Enabled: true, Content: &IndividualCacheConfig{Enabled: ptr(false)}}, + want: false, + }, + { + name: "global disabled overrides per-cache enabled", + config: CacheConfig{Enabled: false, Content: &IndividualCacheConfig{Enabled: ptr(true)}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.config.ContentEnabled() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCacheConfig_BlocksEnabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config CacheConfig + want bool + }{ + { + name: "global disabled", + config: CacheConfig{Enabled: false}, + want: false, + }, + { + name: "global enabled, no per-cache config", + config: CacheConfig{Enabled: true}, + want: true, + }, + { + name: "global enabled, per-cache explicitly disabled", + config: CacheConfig{Enabled: true, Blocks: &IndividualCacheConfig{Enabled: ptr(false)}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.config.BlocksEnabled() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCacheConfig_RefsEnabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config CacheConfig + want bool + }{ + { + name: "global disabled", + config: CacheConfig{Enabled: false}, + want: false, + }, + { + name: "global enabled, no per-cache config", + config: CacheConfig{Enabled: true}, + want: true, + }, + { + name: "global enabled, per-cache explicitly disabled", + config: CacheConfig{Enabled: true, Refs: &IndividualCacheConfig{Enabled: ptr(false)}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.config.RefsEnabled() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCacheConfig_ManifestsEnabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config CacheConfig + want bool + }{ + { + name: "global disabled", + config: CacheConfig{Enabled: false}, + want: false, + }, + { + name: "global enabled, no per-cache config", + config: CacheConfig{Enabled: true}, + want: true, + }, + { + name: "global enabled, per-cache explicitly disabled", + config: CacheConfig{Enabled: true, Manifests: &IndividualCacheConfig{Enabled: ptr(false)}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.config.ManifestsEnabled() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCacheConfig_IndexesEnabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config CacheConfig + want bool + }{ + { + name: "global disabled", + config: CacheConfig{Enabled: false}, + want: false, + }, + { + name: "global enabled, no per-cache config", + config: CacheConfig{Enabled: true}, + want: true, + }, + { + name: "global enabled, per-cache explicitly disabled", + config: CacheConfig{Enabled: true, Indexes: &IndividualCacheConfig{Enabled: ptr(false)}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.config.IndexesEnabled() + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/config/validate.go b/internal/config/validate.go index d2235e4..3e72cb0 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -6,6 +6,7 @@ import ( "regexp" "strconv" "strings" + "time" "unicode" ) @@ -20,12 +21,25 @@ func validate(cfg *Config) error { if err := validateCompression(cfg.Compression); err != nil { return err } - if cfg.Cache.MaxSize != "" { - if err := validateCacheSize(cfg.Cache.MaxSize); err != nil { + if err := validateCache(&cfg.Cache); err != nil { + return err + } + return validatePolicies(cfg.Policies) +} + +// validateCache validates cache configuration. +func validateCache(cache *CacheConfig) error { + if cache.MaxSize != "" { + if err := validateCacheSize(cache.MaxSize); err != nil { return err } } - return validatePolicies(cfg.Policies) + if cache.RefTTL != "" { + if _, err := time.ParseDuration(cache.RefTTL); err != nil { + return fmt.Errorf("%w: cache.ref_ttl must be a valid duration (e.g., 5m, 1h), got %q", ErrInvalidConfig, cache.RefTTL) + } + } + return nil } func validateOutput(v string) error { diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 09758e8..03efa10 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -133,6 +133,56 @@ func TestValidatePolicies(t *testing.T) { } } +func TestValidateCache(t *testing.T) { + tests := []struct { + name string + cache CacheConfig + wantErr bool + }{ + { + name: "empty config", + cache: CacheConfig{}, + wantErr: false, + }, + { + name: "valid ref_ttl", + cache: CacheConfig{RefTTL: "5m"}, + wantErr: false, + }, + { + name: "valid ref_ttl with hours", + cache: CacheConfig{RefTTL: "1h30m"}, + wantErr: false, + }, + { + name: "invalid ref_ttl", + cache: CacheConfig{RefTTL: "invalid"}, + wantErr: true, + }, + { + name: "invalid ref_ttl no unit", + cache: CacheConfig{RefTTL: "5"}, + wantErr: true, + }, + { + name: "valid max_size", + cache: CacheConfig{MaxSize: "5GB"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateCache(&tt.cache) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + func TestValidate(t *testing.T) { tests := []struct { name string @@ -160,6 +210,15 @@ func TestValidate(t *testing.T) { }, wantErr: true, }, + { + name: "invalid cache ref_ttl", + cfg: &Config{ + Output: "text", + Compression: "zstd", + Cache: CacheConfig{RefTTL: "invalid"}, + }, + wantErr: true, + }, } for _, tt := range tests {