package cache import ( "os" "path/filepath" "strings" "testing" "time" "bugabinga.net/luci/internal/ciconfig" ) func TestKeyRestoreAndSave(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "work") if err := os.MkdirAll(filepath.Join(workspace, ".deps"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workspace, "lock"), []byte("v1"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workspace, ".deps", "module"), []byte("cached"), 0o644); err != nil { t.Fatal(err) } entry := ciconfig.Cache{Name: "deps", Path: ".deps", KeyFiles: []string{"lock"}} key, err := Key(workspace, entry) if err != nil || len(key) != 64 { t.Fatalf("key=%q err=%v", key, err) } store := Store{DataDir: filepath.Join(root, "data")} if err := store.Save("repo", "job", entry, key, workspace); err != nil { t.Fatal(err) } if err := os.RemoveAll(filepath.Join(workspace, ".deps")); err != nil { t.Fatal(err) } if _, err := store.Restore("repo", "job", entry, key, workspace); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(workspace, ".deps", "module")) if err != nil || string(data) != "cached" { t.Fatalf("data=%q err=%v", data, err) } if _, err := store.Restore("repo", "job", entry, "missing", workspace); err != nil { t.Fatal(err) } } func TestKeyChangesAndMarksMissingFiles(t *testing.T) { workspace := t.TempDir() entry := ciconfig.Cache{Name: "deps", Path: ".deps", KeyFiles: []string{"missing"}} missing, err := Key(workspace, entry) if err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workspace, "missing"), []byte("present"), 0o644); err != nil { t.Fatal(err) } present, err := Key(workspace, entry) if err != nil || present == missing { t.Fatalf("missing=%q present=%q err=%v", missing, present, err) } } func TestRejectsUnsafePathsSymlinksAndSpecialFiles(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "work") if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatal(err) } if _, err := Key(workspace, ciconfig.Cache{KeyFiles: []string{"../outside"}}); err == nil { t.Fatal("unsafe key accepted") } if err := os.Symlink(root, filepath.Join(workspace, "link")); err != nil { t.Fatal(err) } if _, err := Key(workspace, ciconfig.Cache{KeyFiles: []string{"link/file"}}); err == nil { t.Fatal("symlink key accepted") } store := Store{DataDir: filepath.Join(root, "data")} entry := ciconfig.Cache{Name: "deps", Path: "link/cache"} if err := store.Save("repo", "job", entry, "key", workspace); err == nil { t.Fatal("symlink cache accepted") } entry.Path = "missing" if err := store.Save("repo", "job", entry, "key", workspace); err != nil { t.Fatalf("missing cache failed: %v", err) } if _, err := store.Restore("../repo", "job", entry, "key", workspace); err == nil { t.Fatal("unsafe repo accepted") } } func TestSaveEvictsOldVersions(t *testing.T) { root := t.TempDir() store := Store{DataDir: root, MaxVersions: 2} for i, key := range []string{"aa", "bb", "cc"} { workspace := filepath.Join(root, "w", key) if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workspace, "value"), []byte(key), 0o644); err != nil { t.Fatal(err) } if err := store.Save("repo", "job", ciconfig.Cache{Name: "deps", Path: "value"}, key, workspace); err != nil { t.Fatal(err) } mtime := time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC) if err := os.Chtimes(filepath.Join(root, "cache", "repo", "job", "deps", key), mtime, mtime); err != nil { t.Fatal(err) } } entries, err := os.ReadDir(filepath.Join(root, "cache", "repo", "job", "deps")) if err != nil { t.Fatal(err) } var names []string for _, entry := range entries { if !strings.HasSuffix(entry.Name(), ".lock") && !strings.HasPrefix(entry.Name(), ".") { names = append(names, entry.Name()) } } if len(names) != 2 { t.Fatalf("remaining=%v", names) } for _, name := range names { if name == "aa" { t.Fatalf("oldest survived: %v", names) } } } func TestSaveEvictsToByteBudget(t *testing.T) { root := t.TempDir() store := Store{DataDir: root, MaxVersions: 5, MaxBytes: 3} for i, key := range []string{"aa", "bb", "cc"} { workspace := filepath.Join(root, "w", key) if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workspace, "value"), []byte(key), 0o644); err != nil { t.Fatal(err) } if err := store.Save("repo", "job", ciconfig.Cache{Name: "deps", Path: "value"}, key, workspace); err != nil { t.Fatal(err) } mtime := time.Date(2026, 1, 1, 0, 0, i, 0, time.UTC) if err := os.Chtimes(filepath.Join(root, "cache", "repo", "job", "deps", key), mtime, mtime); err != nil { t.Fatal(err) } } entries, err := os.ReadDir(filepath.Join(root, "cache", "repo", "job", "deps")) if err != nil { t.Fatal(err) } remaining := 0 for _, entry := range entries { if !strings.HasSuffix(entry.Name(), ".lock") && !strings.HasPrefix(entry.Name(), ".") { remaining++ } } if remaining != 1 { t.Fatalf("remaining=%d want 1 (newest only under 3-byte budget)", remaining) } } func TestSaveReservesAggregateBudgetBeforeStaging(t *testing.T) { root := t.TempDir() store := Store{DataDir: root, MaxVersions: 3, MaxBytes: 6} save := func(name, data string) error { workspace := filepath.Join(root, "work", name) if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workspace, "value"), []byte(data), 0o644); err != nil { t.Fatal(err) } return store.Save("repo", "job", ciconfig.Cache{Name: name, Path: "value"}, "key", workspace) } if err := save("first", "1234"); err != nil { t.Fatal(err) } if err := save("second", "5678"); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(root, "cache", "repo", "job", "first", "key")); !os.IsNotExist(err) { t.Fatalf("old aggregate cache remains: %v", err) } if _, err := os.Stat(filepath.Join(root, "cache", "repo", "job", "second", "key")); err != nil { t.Fatalf("new aggregate cache missing: %v", err) } workspace := filepath.Join(root, "oversized") if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workspace, "value"), []byte("1234567"), 0o644); err != nil { t.Fatal(err) } if err := store.Save("repo", "job", ciconfig.Cache{Name: "large", Path: "value"}, "key", workspace); err == nil { t.Fatal("oversized cache staged") } if _, err := os.Stat(filepath.Join(root, "cache", "repo", "job", "large", "key")); !os.IsNotExist(err) { t.Fatalf("oversized cache was stored: %v", err) } } func TestSaveReplacesProtectedVersionWithinAggregateBudget(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "work") if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatal(err) } entry := ciconfig.Cache{Name: "deps", Path: "value"} store := Store{DataDir: root, MaxBytes: 3} for _, value := range []string{"old", "new"} { if err := os.WriteFile(filepath.Join(workspace, "value"), []byte(value), 0o644); err != nil { t.Fatal(err) } if err := store.Save("repo", "job", entry, "key", workspace); err != nil { t.Fatal(err) } } } func TestRestoreFallsBackToNewestVersion(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "work") if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatal(err) } store := Store{DataDir: root, MaxVersions: 3} for i, key := range []string{"old", "new"} { source := filepath.Join(root, "src", key) if err := os.MkdirAll(filepath.Join(source, "deps"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(source, "deps", "marker"), []byte(key), 0o644); err != nil { t.Fatal(err) } if err := store.Save("repo", "job", ciconfig.Cache{Name: "deps", Path: "deps"}, key, source); err != nil { t.Fatal(err) } mtime := time.Date(2026, 1, 1, 0, i, 0, 0, time.UTC) if err := os.Chtimes(filepath.Join(root, "cache", "repo", "job", "deps", key), mtime, mtime); err != nil { t.Fatal(err) } } if _, err := store.Restore("repo", "job", ciconfig.Cache{Name: "deps", Path: ".deps"}, "missing", workspace); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(workspace, ".deps", "marker")) if err != nil || string(data) != "new" { t.Fatalf("restored=%q err=%v; want newest", data, err) } } func TestRestoreWithoutAnyVersionSucceeds(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "work") if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatal(err) } store := Store{DataDir: root} if _, err := store.Restore("repo", "job", ciconfig.Cache{Name: "deps", Path: ".deps"}, "missing", workspace); err != nil { t.Fatalf("empty restore err=%v", err) } } func TestSaveReplacesPreviousCache(t *testing.T) { root := t.TempDir() workspace := filepath.Join(root, "work") path := filepath.Join(workspace, "deps") if err := os.MkdirAll(path, 0o755); err != nil { t.Fatal(err) } entry := ciconfig.Cache{Name: "deps", Path: "deps"} store := Store{DataDir: filepath.Join(root, "data")} for _, value := range []string{"old", "new"} { if err := os.WriteFile(filepath.Join(path, "value"), []byte(value), 0o644); err != nil { t.Fatal(err) } if err := store.Save("repo", "job", entry, "key", workspace); err != nil { t.Fatal(err) } } if err := os.RemoveAll(path); err != nil { t.Fatal(err) } if _, err := store.Restore("repo", "job", entry, "key", workspace); err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(path, "value")) if err != nil || string(data) != "new" { t.Fatalf("data=%q err=%v", data, err) } }