carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package assetcache
2
3import (
4 "errors"
5 "fmt"
6 "math"
7 "net/http"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "testing"
13 "time"
14)
15
16var testNow = time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)
17
18func testKey(app, path string) Key {
19 return Key{Account: "abc", App: app, Gen: "g1", Host: "shop.example.com", Path: path}
20}
21
22func testConfig(dir string) Config {
23 return Config{Dir: dir, Budget: 1 << 20, Now: func() time.Time { return testNow }}
24}
25
26// writeEntry writes an entry's files by hand in the documented layout:
27// <id>.meta plus <id>.<v>.body and, when gz is non-empty, <id>.<v>.gz.
28func writeEntry(t *testing.T, dir string, k Key, version string, body, gz []byte) Meta {
29 t.Helper()
30 return writeEntryAt(t, dir, k, version, body, gz, testNow)
31}
32
33// writeEntryAt is writeEntry with the entry stored at storedAt.
34func writeEntryAt(t *testing.T, dir string, k Key, version string, body, gz []byte, storedAt time.Time) Meta {
35 t.Helper()
36 m := Meta{
37 Key: k,
38 Version: version,
39 Header: http.Header{"Content-Type": {"text/css"}},
40 Lifetime: time.Hour,
41 Expires: testNow.Add(time.Hour),
42 StoredAt: storedAt,
43 Size: int64(len(body)),
44 GzipSize: int64(len(gz)),
45 }
46 writeVersionFiles(t, dir, k.ID(), version, body, gz)
47 raw, err := encodeMeta(m)
48 if err != nil {
49 t.Fatal(err)
50 }
51 writeFile(t, filepath.Join(dir, k.ID()+".meta"), raw)
52 return m
53}
54
55func writeVersionFiles(t *testing.T, dir, id, version string, body, gz []byte) {
56 t.Helper()
57 writeFile(t, filepath.Join(dir, id+"."+version+".body"), body)
58 if len(gz) > 0 {
59 writeFile(t, filepath.Join(dir, id+"."+version+".gz"), gz)
60 }
61}
62
63func writeFile(t *testing.T, path string, data []byte) {
64 t.Helper()
65 if err := os.WriteFile(path, data, 0o600); err != nil {
66 t.Fatal(err)
67 }
68}
69
70func listDir(t *testing.T, dir string) []string {
71 t.Helper()
72 ents, err := os.ReadDir(dir)
73 if err != nil {
74 t.Fatal(err)
75 }
76 var names []string
77 for _, e := range ents {
78 names = append(names, e.Name())
79 }
80 sort.Strings(names)
81 return names
82}
83
84func sameNames(t *testing.T, got, want []string) {
85 t.Helper()
86 sort.Strings(want)
87 if strings.Join(got, "\n") != strings.Join(want, "\n") {
88 t.Fatalf("dir after Open = %v, want %v", got, want)
89 }
90}
91
92// metaFile is the size of k's .meta file, which the store counts with the
93// entry's bodies.
94func metaFile(t *testing.T, dir string, k Key) int64 {
95 t.Helper()
96 fi, err := os.Stat(filepath.Join(dir, k.ID()+".meta"))
97 if err != nil {
98 t.Fatal(err)
99 }
100 return fi.Size()
101}
102
103func openStore(t *testing.T, dir string) *Store {
104 t.Helper()
105 s, err := Open(testConfig(dir))
106 if err != nil {
107 t.Fatalf("Open: %v", err)
108 }
109 return s
110}
111
112func TestKeyIDIsStableAndDistinct(t *testing.T) {
113 a := Key{Account: "abc", App: "shop", Gen: "g1", Host: "shop.example.com", Path: "/a.css", RawQuery: "v=1"}
114 if a.ID() != a.ID() {
115 t.Fatal("same key gave two ids")
116 }
117 if len(a.ID()) != 64 || strings.Trim(a.ID(), "0123456789abcdef") != "" {
118 t.Fatalf("id %q is not 64 hex characters", a.ID())
119 }
120 moved := a
121 moved.Path, moved.RawQuery = "/a.cssv", "=1"
122 if a.ID() == moved.ID() {
123 t.Fatal("moving a character between Path and RawQuery kept the id")
124 }
125 moved = a
126 moved.App, moved.Gen = "shop1", "g"
127 if a.ID() == moved.ID() {
128 t.Fatal("moving a character between App and Gen kept the id")
129 }
130 if got := a.AppScope(); got != "abc/shop" {
131 t.Fatalf("AppScope = %q, want abc/shop", got)
132 }
133}
134
135func TestOpenCreatesTheDirectory(t *testing.T) {
136 dir := filepath.Join(t.TempDir(), "nested", "cache")
137 s := openStore(t, dir)
138 fi, err := os.Stat(dir)
139 if err != nil {
140 t.Fatal(err)
141 }
142 if !fi.IsDir() {
143 t.Fatal("Dir is not a directory")
144 }
145 if perm := fi.Mode().Perm(); perm != 0o700 {
146 t.Fatalf("Dir mode = %o, want 700", perm)
147 }
148 if st := s.Stats(); st != (Stats{}) {
149 t.Fatalf("empty store Stats = %+v", st)
150 }
151}
152
153func TestOpenRejectsBadConfig(t *testing.T) {
154 if _, err := Open(Config{Budget: 1}); err == nil {
155 t.Fatal("Open with no Dir succeeded")
156 }
157 if _, err := Open(Config{Dir: t.TempDir()}); err == nil {
158 t.Fatal("Open with no Budget succeeded")
159 }
160}
161
162func TestOpenRemovesOrphanBodiesAndTemporaries(t *testing.T) {
163 dir := t.TempDir()
164 kept := testKey("shop", "/kept.css")
165 writeEntry(t, dir, kept, "a", []byte("body"), []byte("gz"))
166
167 // A crash after the bodies were written but before the meta rename.
168 orphan := testKey("shop", "/orphan.css")
169 writeVersionFiles(t, dir, orphan.ID(), "a", []byte("orphan body"), []byte("orphan gz"))
170 // Temporaries from an interrupted fill and an interrupted meta write.
171 writeFile(t, filepath.Join(dir, orphan.ID()+".a.body.tmp-123"), []byte("partial"))
172 writeFile(t, filepath.Join(dir, kept.ID()+".meta.tmp-456"), []byte("{"))
173 // A file that is not ours stays.
174 writeFile(t, filepath.Join(dir, "README"), []byte("hi"))
175
176 s := openStore(t, dir)
177
178 sameNames(t, listDir(t, dir), []string{"README", kept.ID() + ".a.body", kept.ID() + ".a.gz", kept.ID() + ".meta"})
179 if st := s.Stats(); st.Entries != 1 || st.Bytes != int64(len("body")+len("gz"))+metaFile(t, dir, kept) {
180 t.Fatalf("Stats = %+v, want 1 entry of 6 bytes", st)
181 }
182}
183
184func TestOpenKeepsMetaWithUnknownFields(t *testing.T) {
185 dir := t.TempDir()
186 k := testKey("shop", "/new.css")
187 m := Meta{Key: k, Version: "a", Lifetime: time.Hour, Expires: testNow.Add(time.Hour), StoredAt: testNow, Size: 4}
188 raw, err := encodeMeta(m)
189 if err != nil {
190 t.Fatal(err)
191 }
192 // Splice an unknown top-level field and an unknown nested one into the
193 // JSON, as a newer binary would have written.
194 s := string(raw)
195 s = strings.Replace(s, `"Version"`, `"FromTheFuture":{"x":[1,2,3]},"Version"`, 1)
196 s = strings.Replace(s, `"Account"`, `"Region":"eu","Account"`, 1)
197 if !strings.Contains(s, "FromTheFuture") || !strings.Contains(s, "Region") {
198 t.Fatalf("could not splice unknown fields into %s", raw)
199 }
200 writeFile(t, filepath.Join(dir, k.ID()+".meta"), []byte(s))
201 writeVersionFiles(t, dir, k.ID(), "a", []byte("body"), nil)
202
203 st := openStore(t, dir).Stats()
204 if st.Entries != 1 || st.Bytes != 4+metaFile(t, dir, k) {
205 t.Fatalf("Stats = %+v, want the entry kept", st)
206 }
207 sameNames(t, listDir(t, dir), []string{k.ID() + ".meta", k.ID() + ".a.body"})
208}
209
210func TestOpenDropsUnparseableMeta(t *testing.T) {
211 dir := t.TempDir()
212 good := testKey("shop", "/good.css")
213 writeEntry(t, dir, good, "a", []byte("good"), nil)
214
215 bad := testKey("shop", "/bad.css")
216 writeVersionFiles(t, dir, bad.ID(), "a", []byte("bad body"), []byte("bad gz"))
217 writeFile(t, filepath.Join(dir, bad.ID()+".meta"), []byte(`{"Key": {"Acc`))
218
219 // A meta that parses but names a different key than its file name
220 // has been moved or tampered with, so it is dropped too.
221 moved := testKey("shop", "/moved.css")
222 other := testKey("blog", "/other.css")
223 m := writeEntry(t, dir, other, "a", []byte("other"), nil)
224 raw, err := encodeMeta(m)
225 if err != nil {
226 t.Fatal(err)
227 }
228 writeFile(t, filepath.Join(dir, moved.ID()+".meta"), raw)
229 writeVersionFiles(t, dir, moved.ID(), "a", []byte("moved"), nil)
230
231 // A meta whose version would escape the directory is dropped too.
232 evil := testKey("shop", "/evil.css")
233 raw, err = encodeMeta(Meta{Key: evil, Version: "../x", Size: 1})
234 if err != nil {
235 t.Fatal(err)
236 }
237 writeFile(t, filepath.Join(dir, evil.ID()+".meta"), raw)
238
239 st := openStore(t, dir).Stats()
240 if st.Entries != 2 || st.Bytes != int64(len("good")+len("other"))+metaFile(t, dir, good)+metaFile(t, dir, other) {
241 t.Fatalf("Stats = %+v, want only the good and other entries", st)
242 }
243 sameNames(t, listDir(t, dir), []string{
244 good.ID() + ".a.body", good.ID() + ".meta",
245 other.ID() + ".a.body", other.ID() + ".meta",
246 })
247}
248
249func TestOpenDropsEntryWhoseBodyIsMissing(t *testing.T) {
250 dir := t.TempDir()
251 k := testKey("shop", "/half.css")
252 writeEntry(t, dir, k, "a", []byte("body"), []byte("gz"))
253 if err := os.Remove(filepath.Join(dir, k.ID()+".a.gz")); err != nil {
254 t.Fatal(err)
255 }
256 s := openStore(t, dir)
257 if st := s.Stats(); st != (Stats{}) {
258 t.Fatalf("Stats = %+v, want the half entry dropped", st)
259 }
260 sameNames(t, listDir(t, dir), nil)
261}
262
263func TestOpenIndexesExistingEntries(t *testing.T) {
264 dir := t.TempDir()
265 writeEntry(t, dir, testKey("shop", "/a.css"), "a", []byte("aaaa"), []byte("aa"))
266 writeEntry(t, dir, testKey("shop", "/b.js"), "b", []byte("bbbbbb"), nil)
267 writeEntry(t, dir, testKey("blog", "/c.png"), "c", []byte("cc"), nil)
268
269 s := openStore(t, dir)
270 st := s.Stats()
271 metas := metaFile(t, dir, testKey("shop", "/a.css")) + metaFile(t, dir, testKey("shop", "/b.js")) + metaFile(t, dir, testKey("blog", "/c.png"))
272 want := Stats{Entries: 3, Bytes: 4 + 2 + 6 + 2 + metas, Reserved: 0, Apps: 2}
273 if st != want {
274 t.Fatalf("Stats = %+v, want %+v", st, want)
275 }
276 if n := len(listDir(t, dir)); n != 3+4 {
277 t.Fatalf("%d files after Open, want 7", n)
278 }
279}
280
281func TestOpenDeletesSupersededVersions(t *testing.T) {
282 dir := t.TempDir()
283 k := testKey("shop", "/a.css")
284 // Crash after the meta rename but before the old version was deleted.
285 writeVersionFiles(t, dir, k.ID(), "a", []byte("old body"), []byte("old gz"))
286 writeEntry(t, dir, k, "b", []byte("new"), []byte("n"))
287
288 s := openStore(t, dir)
289 sameNames(t, listDir(t, dir), []string{k.ID() + ".b.body", k.ID() + ".b.gz", k.ID() + ".meta"})
290 if st := s.Stats(); st.Entries != 1 || st.Bytes != 4+metaFile(t, dir, k) {
291 t.Fatalf("Stats = %+v, want 1 entry of 4 bytes and its meta", st)
292 }
293}
294
295func TestOpenFailsOnUnreadableMetaAndKeepsEntry(t *testing.T) {
296 if os.Geteuid() == 0 {
297 t.Skip("running as root: a 0000-mode file is still readable, so this cannot provoke a read error")
298 }
299 dir := t.TempDir()
300 k := testKey("shop", "/a.css")
301 writeEntry(t, dir, k, "a", []byte("body"), []byte("gz"))
302 metaPath := filepath.Join(dir, k.ID()+".meta")
303 if err := os.Chmod(metaPath, 0); err != nil {
304 t.Fatal(err)
305 }
306 defer os.Chmod(metaPath, 0o600)
307
308 if _, err := Open(testConfig(dir)); err == nil {
309 t.Fatal("Open succeeded over an unreadable meta")
310 }
311 sameNames(t, listDir(t, dir), []string{k.ID() + ".meta", k.ID() + ".a.body", k.ID() + ".a.gz"})
312
313 // Once the fault clears the entry is still there.
314 if err := os.Chmod(metaPath, 0o600); err != nil {
315 t.Fatal(err)
316 }
317 if st := openStore(t, dir).Stats(); st.Entries != 1 || st.Bytes != 6+metaFile(t, dir, k) {
318 t.Fatalf("Stats = %+v, want the entry intact", st)
319 }
320}
321
322func TestOpenDropsEntryWhoseBodySizeDiffers(t *testing.T) {
323 for _, tc := range []struct {
324 name string
325 edit func(t *testing.T, dir string, k Key)
326 }{
327 {"identity truncated", func(t *testing.T, dir string, k Key) {
328 writeFile(t, filepath.Join(dir, k.ID()+".a.body"), []byte("bo"))
329 }},
330 {"identity longer than recorded", func(t *testing.T, dir string, k Key) {
331 writeFile(t, filepath.Join(dir, k.ID()+".a.body"), []byte("body and more"))
332 }},
333 {"gzip truncated", func(t *testing.T, dir string, k Key) {
334 writeFile(t, filepath.Join(dir, k.ID()+".a.gz"), []byte("g"))
335 }},
336 {"gzip longer than recorded", func(t *testing.T, dir string, k Key) {
337 writeFile(t, filepath.Join(dir, k.ID()+".a.gz"), []byte("gzip and more"))
338 }},
339 } {
340 t.Run(tc.name, func(t *testing.T) {
341 dir := t.TempDir()
342 k := testKey("shop", "/a.css")
343 writeEntry(t, dir, k, "a", []byte("body"), []byte("gz"))
344 tc.edit(t, dir, k)
345 s := openStore(t, dir)
346 if st := s.Stats(); st != (Stats{}) {
347 t.Fatalf("Stats = %+v, want the entry dropped", st)
348 }
349 sameNames(t, listDir(t, dir), nil)
350 })
351 }
352}
353
354func TestOpenDropsMetaWithOverflowingSizes(t *testing.T) {
355 dir := t.TempDir()
356 k := testKey("shop", "/a.css")
357 raw, err := encodeMeta(Meta{Key: k, Version: "a", Size: math.MaxInt64, GzipSize: math.MaxInt64})
358 if err != nil {
359 t.Fatal(err)
360 }
361 writeFile(t, filepath.Join(dir, k.ID()+".meta"), raw)
362 writeVersionFiles(t, dir, k.ID(), "a", []byte("body"), []byte("gz"))
363
364 s := openStore(t, dir)
365 if st := s.Stats(); st != (Stats{}) {
366 t.Fatalf("Stats = %+v, want the entry dropped", st)
367 }
368 sameNames(t, listDir(t, dir), nil)
369}
370
371// TestDecodeMetaRefusesOverflowingSizes checks the overflow guard on its own:
372// the Open test above also fails the body size check, so it would pass
373// without the guard.
374func TestDecodeMetaRefusesOverflowingSizes(t *testing.T) {
375 for _, c := range []struct {
376 size, gzip int64
377 ok bool
378 }{
379 {math.MaxInt64, 0, true},
380 {math.MaxInt64 - 1, 1, true},
381 {math.MaxInt64, 1, false},
382 {1, math.MaxInt64, false},
383 {math.MaxInt64, math.MaxInt64, false},
384 {-1, 0, false},
385 {0, -1, false},
386 } {
387 raw, err := encodeMeta(Meta{Key: testKey("shop", "/a.css"), Version: "a", Size: c.size, GzipSize: c.gzip})
388 if err != nil {
389 t.Fatal(err)
390 }
391 if _, err := decodeMeta(raw); (err == nil) != c.ok {
392 t.Errorf("decodeMeta with sizes %d and %d: err = %v, want ok=%v", c.size, c.gzip, err, c.ok)
393 }
394 }
395}
396
397// shopKeysByID returns n keys of app shop in the order Open's directory
398// listing finds their .meta files: by id.
399func shopKeysByID(n int) []Key {
400 keys := make([]Key, n)
401 for i := range keys {
402 keys[i] = testKey("shop", fmt.Sprintf("/%d.css", i))
403 }
404 sort.Slice(keys, func(i, j int) bool { return keys[i].ID() < keys[j].ID() })
405 return keys
406}
407
408// writeNewestFirst writes one entry per key, each an hour older than the one
409// before, so the first file name is the newest: an index that took recency
410// from file names would have it backwards.
411func writeNewestFirst(t *testing.T, dir string, keys []Key, body []byte) {
412 t.Helper()
413 for i, k := range keys {
414 writeEntryAt(t, dir, k, "a", body, nil, testNow.Add(-time.Duration(i)*time.Hour))
415 }
416}
417
418// TestOpenSeedsRecencyFromStoredAt: after a restart the least recently used
419// entry is the one stored longest ago, whatever its file name.
420func TestOpenSeedsRecencyFromStoredAt(t *testing.T) {
421 dir := t.TempDir()
422 keys := shopKeysByID(4)
423 writeNewestFirst(t, dir, keys, []byte("body"))
424 s := openStore(t, dir)
425 s.mu.Lock()
426 defer s.mu.Unlock()
427 var got []Key
428 for el := s.apps["abc/shop"].lru.Front(); el != nil; el = el.Next() {
429 got = append(got, el.Value.(*entry).meta.Key)
430 }
431 for i := range keys {
432 if i >= len(got) || got[i] != keys[i] {
433 t.Fatalf("recency after Open, most recent first = %v, want %v (newest StoredAt first)", got, keys)
434 }
435 }
436}
437
438// overBudgetDir writes six shop entries and one blog entry of 20000 bytes
439// each, about 142 kB, for a store opened with a 100000-byte budget.
440func overBudgetDir(t *testing.T) (dir string, shop []Key, blog Key) {
441 t.Helper()
442 dir = t.TempDir()
443 body := []byte(strings.Repeat("x", 20000))
444 shop = shopKeysByID(6)
445 writeNewestFirst(t, dir, shop, body)
446 blog = testKey("blog", "/b.css")
447 writeEntryAt(t, dir, blog, "a", body, nil, testNow.Add(-24*time.Hour))
448 return dir, shop, blog
449}
450
451func smallBudgetStore(t *testing.T, dir string) (*Store, error) {
452 return Open(Config{Dir: dir, Budget: 100000, FS: &fakeFS{total: 64 << 30, free: 64 << 30}, Now: func() time.Time { return testNow }})
453}
454
455// TestOpenEnforcesASmallerBudget is not acceptance: it uses a fake FS. A
456// cache written under a larger budget is reopened under a smaller one. Open
457// evicts by the fair-share rule until it fits: from shop, the app furthest
458// over its share, oldest stored first, and nothing of blog, which is within
459// its share although its entry is the oldest of all.
460func TestOpenEnforcesASmallerBudget(t *testing.T) {
461 dir, shop, blog := overBudgetDir(t)
462 s, err := smallBudgetStore(t, dir)
463 if err != nil {
464 t.Fatalf("Open: %v", err)
465 }
466 st := s.Stats()
467 if st.Bytes > s.cfg.Budget || st.Retired != 0 {
468 t.Fatalf("Stats after Open = %+v, want within the budget %d", st, s.cfg.Budget)
469 }
470 if !present(s, "blog", blog.Path) {
471 t.Fatal("blog's entry was evicted, want kept: blog is within its share")
472 }
473 kept := 0
474 for kept < len(shop) && present(s, "shop", shop[kept].Path) {
475 kept++
476 }
477 for _, k := range shop[kept:] {
478 if present(s, "shop", k.Path) {
479 t.Fatalf("shop kept %s but evicted a newer entry", k.Path)
480 }
481 if _, err := os.Stat(filepath.Join(dir, metaName(k.ID()))); !os.IsNotExist(err) {
482 t.Fatalf("evicted %s still has its .meta: %v", k.Path, err)
483 }
484 }
485 if kept == 0 || kept == len(shop) {
486 t.Fatalf("shop kept %d of %d entries, want the newest few", kept, len(shop))
487 }
488 // Only as much as needed went: one more entry would not fit.
489 if one := 20000 + metaFile(t, dir, shop[0]); st.Bytes+one <= s.cfg.Budget {
490 t.Fatalf("Stats = %+v: room for another %d-byte entry, Open evicted too much", st, one)
491 }
492}
493
494// TestOpenOverBudgetRefusesFillsUntilBackUnder is not acceptance: it uses a
495// fake FS. When the entries Open would evict cannot be deleted (the
496// directory is read-only), Open still succeeds, over its budget, and every
497// fill is refused, each retrying the deletions, until the store is back
498// under its budget.
499func TestOpenOverBudgetRefusesFillsUntilBackUnder(t *testing.T) {
500 if os.Geteuid() == 0 {
501 t.Skip("root ignores directory permissions")
502 }
503 dir, _, _ := overBudgetDir(t)
504 if err := os.Chmod(dir, 0o500); err != nil {
505 t.Fatal(err)
506 }
507 t.Cleanup(func() { os.Chmod(dir, 0o700) })
508 s, err := smallBudgetStore(t, dir)
509 if err != nil {
510 t.Fatalf("Open over budget with undeletable entries: %v, want it to succeed", err)
511 }
512 if st := s.Stats(); st.Entries != 7 || st.Bytes <= s.cfg.Budget {
513 t.Fatalf("Stats = %+v, want all 7 entries, over the budget", st)
514 }
515 probe := testKey("blog", "/probe")
516 if _, err := s.Reserve(probe, 10); !errors.Is(err, ErrNoRoom) {
517 t.Fatalf("Reserve over budget: err = %v, want ErrNoRoom", err)
518 }
519 if err := os.Chmod(dir, 0o700); err != nil {
520 t.Fatal(err)
521 }
522 f, err := s.Reserve(probe, 10)
523 if err != nil {
524 t.Fatalf("Reserve once deletion works: %v", err)
525 }
526 defer f.Abort()
527 if st := s.Stats(); st.Bytes+st.Reserved > s.cfg.Budget {
528 t.Fatalf("Stats = %+v, want within the budget %d", st, s.cfg.Budget)
529 }
530}
531