package assetcache import ( "errors" "fmt" "math" "net/http" "os" "path/filepath" "sort" "strings" "testing" "time" ) var testNow = time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC) func testKey(app, path string) Key { return Key{Account: "abc", App: app, Gen: "g1", Host: "shop.example.com", Path: path} } func testConfig(dir string) Config { return Config{Dir: dir, Budget: 1 << 20, Now: func() time.Time { return testNow }} } // writeEntry writes an entry's files by hand in the documented layout: // .meta plus ..body and, when gz is non-empty, ..gz. func writeEntry(t *testing.T, dir string, k Key, version string, body, gz []byte) Meta { t.Helper() return writeEntryAt(t, dir, k, version, body, gz, testNow) } // writeEntryAt is writeEntry with the entry stored at storedAt. func writeEntryAt(t *testing.T, dir string, k Key, version string, body, gz []byte, storedAt time.Time) Meta { t.Helper() m := Meta{ Key: k, Version: version, Header: http.Header{"Content-Type": {"text/css"}}, Lifetime: time.Hour, Expires: testNow.Add(time.Hour), StoredAt: storedAt, Size: int64(len(body)), GzipSize: int64(len(gz)), } writeVersionFiles(t, dir, k.ID(), version, body, gz) raw, err := encodeMeta(m) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(dir, k.ID()+".meta"), raw) return m } func writeVersionFiles(t *testing.T, dir, id, version string, body, gz []byte) { t.Helper() writeFile(t, filepath.Join(dir, id+"."+version+".body"), body) if len(gz) > 0 { writeFile(t, filepath.Join(dir, id+"."+version+".gz"), gz) } } func writeFile(t *testing.T, path string, data []byte) { t.Helper() if err := os.WriteFile(path, data, 0o600); err != nil { t.Fatal(err) } } func listDir(t *testing.T, dir string) []string { t.Helper() ents, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } var names []string for _, e := range ents { names = append(names, e.Name()) } sort.Strings(names) return names } func sameNames(t *testing.T, got, want []string) { t.Helper() sort.Strings(want) if strings.Join(got, "\n") != strings.Join(want, "\n") { t.Fatalf("dir after Open = %v, want %v", got, want) } } // metaFile is the size of k's .meta file, which the store counts with the // entry's bodies. func metaFile(t *testing.T, dir string, k Key) int64 { t.Helper() fi, err := os.Stat(filepath.Join(dir, k.ID()+".meta")) if err != nil { t.Fatal(err) } return fi.Size() } func openStore(t *testing.T, dir string) *Store { t.Helper() s, err := Open(testConfig(dir)) if err != nil { t.Fatalf("Open: %v", err) } return s } func TestKeyIDIsStableAndDistinct(t *testing.T) { a := Key{Account: "abc", App: "shop", Gen: "g1", Host: "shop.example.com", Path: "/a.css", RawQuery: "v=1"} if a.ID() != a.ID() { t.Fatal("same key gave two ids") } if len(a.ID()) != 64 || strings.Trim(a.ID(), "0123456789abcdef") != "" { t.Fatalf("id %q is not 64 hex characters", a.ID()) } moved := a moved.Path, moved.RawQuery = "/a.cssv", "=1" if a.ID() == moved.ID() { t.Fatal("moving a character between Path and RawQuery kept the id") } moved = a moved.App, moved.Gen = "shop1", "g" if a.ID() == moved.ID() { t.Fatal("moving a character between App and Gen kept the id") } if got := a.AppScope(); got != "abc/shop" { t.Fatalf("AppScope = %q, want abc/shop", got) } } func TestOpenCreatesTheDirectory(t *testing.T) { dir := filepath.Join(t.TempDir(), "nested", "cache") s := openStore(t, dir) fi, err := os.Stat(dir) if err != nil { t.Fatal(err) } if !fi.IsDir() { t.Fatal("Dir is not a directory") } if perm := fi.Mode().Perm(); perm != 0o700 { t.Fatalf("Dir mode = %o, want 700", perm) } if st := s.Stats(); st != (Stats{}) { t.Fatalf("empty store Stats = %+v", st) } } func TestOpenRejectsBadConfig(t *testing.T) { if _, err := Open(Config{Budget: 1}); err == nil { t.Fatal("Open with no Dir succeeded") } if _, err := Open(Config{Dir: t.TempDir()}); err == nil { t.Fatal("Open with no Budget succeeded") } } func TestOpenRemovesOrphanBodiesAndTemporaries(t *testing.T) { dir := t.TempDir() kept := testKey("shop", "/kept.css") writeEntry(t, dir, kept, "a", []byte("body"), []byte("gz")) // A crash after the bodies were written but before the meta rename. orphan := testKey("shop", "/orphan.css") writeVersionFiles(t, dir, orphan.ID(), "a", []byte("orphan body"), []byte("orphan gz")) // Temporaries from an interrupted fill and an interrupted meta write. writeFile(t, filepath.Join(dir, orphan.ID()+".a.body.tmp-123"), []byte("partial")) writeFile(t, filepath.Join(dir, kept.ID()+".meta.tmp-456"), []byte("{")) // A file that is not ours stays. writeFile(t, filepath.Join(dir, "README"), []byte("hi")) s := openStore(t, dir) sameNames(t, listDir(t, dir), []string{"README", kept.ID() + ".a.body", kept.ID() + ".a.gz", kept.ID() + ".meta"}) if st := s.Stats(); st.Entries != 1 || st.Bytes != int64(len("body")+len("gz"))+metaFile(t, dir, kept) { t.Fatalf("Stats = %+v, want 1 entry of 6 bytes", st) } } func TestOpenKeepsMetaWithUnknownFields(t *testing.T) { dir := t.TempDir() k := testKey("shop", "/new.css") m := Meta{Key: k, Version: "a", Lifetime: time.Hour, Expires: testNow.Add(time.Hour), StoredAt: testNow, Size: 4} raw, err := encodeMeta(m) if err != nil { t.Fatal(err) } // Splice an unknown top-level field and an unknown nested one into the // JSON, as a newer binary would have written. s := string(raw) s = strings.Replace(s, `"Version"`, `"FromTheFuture":{"x":[1,2,3]},"Version"`, 1) s = strings.Replace(s, `"Account"`, `"Region":"eu","Account"`, 1) if !strings.Contains(s, "FromTheFuture") || !strings.Contains(s, "Region") { t.Fatalf("could not splice unknown fields into %s", raw) } writeFile(t, filepath.Join(dir, k.ID()+".meta"), []byte(s)) writeVersionFiles(t, dir, k.ID(), "a", []byte("body"), nil) st := openStore(t, dir).Stats() if st.Entries != 1 || st.Bytes != 4+metaFile(t, dir, k) { t.Fatalf("Stats = %+v, want the entry kept", st) } sameNames(t, listDir(t, dir), []string{k.ID() + ".meta", k.ID() + ".a.body"}) } func TestOpenDropsUnparseableMeta(t *testing.T) { dir := t.TempDir() good := testKey("shop", "/good.css") writeEntry(t, dir, good, "a", []byte("good"), nil) bad := testKey("shop", "/bad.css") writeVersionFiles(t, dir, bad.ID(), "a", []byte("bad body"), []byte("bad gz")) writeFile(t, filepath.Join(dir, bad.ID()+".meta"), []byte(`{"Key": {"Acc`)) // A meta that parses but names a different key than its file name // has been moved or tampered with, so it is dropped too. moved := testKey("shop", "/moved.css") other := testKey("blog", "/other.css") m := writeEntry(t, dir, other, "a", []byte("other"), nil) raw, err := encodeMeta(m) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(dir, moved.ID()+".meta"), raw) writeVersionFiles(t, dir, moved.ID(), "a", []byte("moved"), nil) // A meta whose version would escape the directory is dropped too. evil := testKey("shop", "/evil.css") raw, err = encodeMeta(Meta{Key: evil, Version: "../x", Size: 1}) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(dir, evil.ID()+".meta"), raw) st := openStore(t, dir).Stats() if st.Entries != 2 || st.Bytes != int64(len("good")+len("other"))+metaFile(t, dir, good)+metaFile(t, dir, other) { t.Fatalf("Stats = %+v, want only the good and other entries", st) } sameNames(t, listDir(t, dir), []string{ good.ID() + ".a.body", good.ID() + ".meta", other.ID() + ".a.body", other.ID() + ".meta", }) } func TestOpenDropsEntryWhoseBodyIsMissing(t *testing.T) { dir := t.TempDir() k := testKey("shop", "/half.css") writeEntry(t, dir, k, "a", []byte("body"), []byte("gz")) if err := os.Remove(filepath.Join(dir, k.ID()+".a.gz")); err != nil { t.Fatal(err) } s := openStore(t, dir) if st := s.Stats(); st != (Stats{}) { t.Fatalf("Stats = %+v, want the half entry dropped", st) } sameNames(t, listDir(t, dir), nil) } func TestOpenIndexesExistingEntries(t *testing.T) { dir := t.TempDir() writeEntry(t, dir, testKey("shop", "/a.css"), "a", []byte("aaaa"), []byte("aa")) writeEntry(t, dir, testKey("shop", "/b.js"), "b", []byte("bbbbbb"), nil) writeEntry(t, dir, testKey("blog", "/c.png"), "c", []byte("cc"), nil) s := openStore(t, dir) st := s.Stats() metas := metaFile(t, dir, testKey("shop", "/a.css")) + metaFile(t, dir, testKey("shop", "/b.js")) + metaFile(t, dir, testKey("blog", "/c.png")) want := Stats{Entries: 3, Bytes: 4 + 2 + 6 + 2 + metas, Reserved: 0, Apps: 2} if st != want { t.Fatalf("Stats = %+v, want %+v", st, want) } if n := len(listDir(t, dir)); n != 3+4 { t.Fatalf("%d files after Open, want 7", n) } } func TestOpenDeletesSupersededVersions(t *testing.T) { dir := t.TempDir() k := testKey("shop", "/a.css") // Crash after the meta rename but before the old version was deleted. writeVersionFiles(t, dir, k.ID(), "a", []byte("old body"), []byte("old gz")) writeEntry(t, dir, k, "b", []byte("new"), []byte("n")) s := openStore(t, dir) sameNames(t, listDir(t, dir), []string{k.ID() + ".b.body", k.ID() + ".b.gz", k.ID() + ".meta"}) if st := s.Stats(); st.Entries != 1 || st.Bytes != 4+metaFile(t, dir, k) { t.Fatalf("Stats = %+v, want 1 entry of 4 bytes and its meta", st) } } func TestOpenFailsOnUnreadableMetaAndKeepsEntry(t *testing.T) { if os.Geteuid() == 0 { t.Skip("running as root: a 0000-mode file is still readable, so this cannot provoke a read error") } dir := t.TempDir() k := testKey("shop", "/a.css") writeEntry(t, dir, k, "a", []byte("body"), []byte("gz")) metaPath := filepath.Join(dir, k.ID()+".meta") if err := os.Chmod(metaPath, 0); err != nil { t.Fatal(err) } defer os.Chmod(metaPath, 0o600) if _, err := Open(testConfig(dir)); err == nil { t.Fatal("Open succeeded over an unreadable meta") } sameNames(t, listDir(t, dir), []string{k.ID() + ".meta", k.ID() + ".a.body", k.ID() + ".a.gz"}) // Once the fault clears the entry is still there. if err := os.Chmod(metaPath, 0o600); err != nil { t.Fatal(err) } if st := openStore(t, dir).Stats(); st.Entries != 1 || st.Bytes != 6+metaFile(t, dir, k) { t.Fatalf("Stats = %+v, want the entry intact", st) } } func TestOpenDropsEntryWhoseBodySizeDiffers(t *testing.T) { for _, tc := range []struct { name string edit func(t *testing.T, dir string, k Key) }{ {"identity truncated", func(t *testing.T, dir string, k Key) { writeFile(t, filepath.Join(dir, k.ID()+".a.body"), []byte("bo")) }}, {"identity longer than recorded", func(t *testing.T, dir string, k Key) { writeFile(t, filepath.Join(dir, k.ID()+".a.body"), []byte("body and more")) }}, {"gzip truncated", func(t *testing.T, dir string, k Key) { writeFile(t, filepath.Join(dir, k.ID()+".a.gz"), []byte("g")) }}, {"gzip longer than recorded", func(t *testing.T, dir string, k Key) { writeFile(t, filepath.Join(dir, k.ID()+".a.gz"), []byte("gzip and more")) }}, } { t.Run(tc.name, func(t *testing.T) { dir := t.TempDir() k := testKey("shop", "/a.css") writeEntry(t, dir, k, "a", []byte("body"), []byte("gz")) tc.edit(t, dir, k) s := openStore(t, dir) if st := s.Stats(); st != (Stats{}) { t.Fatalf("Stats = %+v, want the entry dropped", st) } sameNames(t, listDir(t, dir), nil) }) } } func TestOpenDropsMetaWithOverflowingSizes(t *testing.T) { dir := t.TempDir() k := testKey("shop", "/a.css") raw, err := encodeMeta(Meta{Key: k, Version: "a", Size: math.MaxInt64, GzipSize: math.MaxInt64}) if err != nil { t.Fatal(err) } writeFile(t, filepath.Join(dir, k.ID()+".meta"), raw) writeVersionFiles(t, dir, k.ID(), "a", []byte("body"), []byte("gz")) s := openStore(t, dir) if st := s.Stats(); st != (Stats{}) { t.Fatalf("Stats = %+v, want the entry dropped", st) } sameNames(t, listDir(t, dir), nil) } // TestDecodeMetaRefusesOverflowingSizes checks the overflow guard on its own: // the Open test above also fails the body size check, so it would pass // without the guard. func TestDecodeMetaRefusesOverflowingSizes(t *testing.T) { for _, c := range []struct { size, gzip int64 ok bool }{ {math.MaxInt64, 0, true}, {math.MaxInt64 - 1, 1, true}, {math.MaxInt64, 1, false}, {1, math.MaxInt64, false}, {math.MaxInt64, math.MaxInt64, false}, {-1, 0, false}, {0, -1, false}, } { raw, err := encodeMeta(Meta{Key: testKey("shop", "/a.css"), Version: "a", Size: c.size, GzipSize: c.gzip}) if err != nil { t.Fatal(err) } if _, err := decodeMeta(raw); (err == nil) != c.ok { t.Errorf("decodeMeta with sizes %d and %d: err = %v, want ok=%v", c.size, c.gzip, err, c.ok) } } } // shopKeysByID returns n keys of app shop in the order Open's directory // listing finds their .meta files: by id. func shopKeysByID(n int) []Key { keys := make([]Key, n) for i := range keys { keys[i] = testKey("shop", fmt.Sprintf("/%d.css", i)) } sort.Slice(keys, func(i, j int) bool { return keys[i].ID() < keys[j].ID() }) return keys } // writeNewestFirst writes one entry per key, each an hour older than the one // before, so the first file name is the newest: an index that took recency // from file names would have it backwards. func writeNewestFirst(t *testing.T, dir string, keys []Key, body []byte) { t.Helper() for i, k := range keys { writeEntryAt(t, dir, k, "a", body, nil, testNow.Add(-time.Duration(i)*time.Hour)) } } // TestOpenSeedsRecencyFromStoredAt: after a restart the least recently used // entry is the one stored longest ago, whatever its file name. func TestOpenSeedsRecencyFromStoredAt(t *testing.T) { dir := t.TempDir() keys := shopKeysByID(4) writeNewestFirst(t, dir, keys, []byte("body")) s := openStore(t, dir) s.mu.Lock() defer s.mu.Unlock() var got []Key for el := s.apps["abc/shop"].lru.Front(); el != nil; el = el.Next() { got = append(got, el.Value.(*entry).meta.Key) } for i := range keys { if i >= len(got) || got[i] != keys[i] { t.Fatalf("recency after Open, most recent first = %v, want %v (newest StoredAt first)", got, keys) } } } // overBudgetDir writes six shop entries and one blog entry of 20000 bytes // each, about 142 kB, for a store opened with a 100000-byte budget. func overBudgetDir(t *testing.T) (dir string, shop []Key, blog Key) { t.Helper() dir = t.TempDir() body := []byte(strings.Repeat("x", 20000)) shop = shopKeysByID(6) writeNewestFirst(t, dir, shop, body) blog = testKey("blog", "/b.css") writeEntryAt(t, dir, blog, "a", body, nil, testNow.Add(-24*time.Hour)) return dir, shop, blog } func smallBudgetStore(t *testing.T, dir string) (*Store, error) { return Open(Config{Dir: dir, Budget: 100000, FS: &fakeFS{total: 64 << 30, free: 64 << 30}, Now: func() time.Time { return testNow }}) } // TestOpenEnforcesASmallerBudget is not acceptance: it uses a fake FS. A // cache written under a larger budget is reopened under a smaller one. Open // evicts by the fair-share rule until it fits: from shop, the app furthest // over its share, oldest stored first, and nothing of blog, which is within // its share although its entry is the oldest of all. func TestOpenEnforcesASmallerBudget(t *testing.T) { dir, shop, blog := overBudgetDir(t) s, err := smallBudgetStore(t, dir) if err != nil { t.Fatalf("Open: %v", err) } st := s.Stats() if st.Bytes > s.cfg.Budget || st.Retired != 0 { t.Fatalf("Stats after Open = %+v, want within the budget %d", st, s.cfg.Budget) } if !present(s, "blog", blog.Path) { t.Fatal("blog's entry was evicted, want kept: blog is within its share") } kept := 0 for kept < len(shop) && present(s, "shop", shop[kept].Path) { kept++ } for _, k := range shop[kept:] { if present(s, "shop", k.Path) { t.Fatalf("shop kept %s but evicted a newer entry", k.Path) } if _, err := os.Stat(filepath.Join(dir, metaName(k.ID()))); !os.IsNotExist(err) { t.Fatalf("evicted %s still has its .meta: %v", k.Path, err) } } if kept == 0 || kept == len(shop) { t.Fatalf("shop kept %d of %d entries, want the newest few", kept, len(shop)) } // Only as much as needed went: one more entry would not fit. if one := 20000 + metaFile(t, dir, shop[0]); st.Bytes+one <= s.cfg.Budget { t.Fatalf("Stats = %+v: room for another %d-byte entry, Open evicted too much", st, one) } } // TestOpenOverBudgetRefusesFillsUntilBackUnder is not acceptance: it uses a // fake FS. When the entries Open would evict cannot be deleted (the // directory is read-only), Open still succeeds, over its budget, and every // fill is refused, each retrying the deletions, until the store is back // under its budget. func TestOpenOverBudgetRefusesFillsUntilBackUnder(t *testing.T) { if os.Geteuid() == 0 { t.Skip("root ignores directory permissions") } dir, _, _ := overBudgetDir(t) if err := os.Chmod(dir, 0o500); err != nil { t.Fatal(err) } t.Cleanup(func() { os.Chmod(dir, 0o700) }) s, err := smallBudgetStore(t, dir) if err != nil { t.Fatalf("Open over budget with undeletable entries: %v, want it to succeed", err) } if st := s.Stats(); st.Entries != 7 || st.Bytes <= s.cfg.Budget { t.Fatalf("Stats = %+v, want all 7 entries, over the budget", st) } probe := testKey("blog", "/probe") if _, err := s.Reserve(probe, 10); !errors.Is(err, ErrNoRoom) { t.Fatalf("Reserve over budget: err = %v, want ErrNoRoom", err) } if err := os.Chmod(dir, 0o700); err != nil { t.Fatal(err) } f, err := s.Reserve(probe, 10) if err != nil { t.Fatalf("Reserve once deletion works: %v", err) } defer f.Abort() if st := s.Stats(); st.Bytes+st.Reserved > s.cfg.Budget { t.Fatalf("Stats = %+v, want within the budget %d", st, s.cfg.Budget) } }