1// SPDX-License-Identifier: MIT
2
3package actionscache
4
5import (
6 "context"
7 "errors"
8 "io"
9 "os"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "testing"
14 "time"
15
16 "github.com/bnema/zerowrap"
17)
18
19func newCache(t *testing.T, max int64) (*Cache, *time.Time) {
20 t.Helper()
21 now := time.Date(2026, 10, 16, 0, 0, 0, 0, time.UTC)
22 c, err := New(Config{Dir: t.TempDir(), Max: max, Now: func() time.Time { return now }, Log: zerowrap.Logger{}})
23 if err != nil {
24 t.Fatal(err)
25 }
26 return c, &now
27}
28
29func keyOf(t *testing.T, n int) string {
30 t.Helper()
31 k, ok := Key("https://github.com", "o/r"+strings.Repeat("x", n), strings.Repeat("a", 40))
32 if !ok {
33 t.Fatal("key")
34 }
35 return k
36}
37
38func body(s string) Fetch {
39 return func(_ context.Context, w io.Writer) error { _, err := io.WriteString(w, s); return err }
40}
41
42func read(t *testing.T, c *Cache, key string) string {
43 t.Helper()
44 f, err := c.Open(key)
45 if err != nil {
46 t.Fatal(err)
47 }
48 defer func() { _ = f.Close() }()
49 b, _ := io.ReadAll(f)
50 return string(b)
51}
52
53func TestHitMiss(t *testing.T) {
54 c, _ := newCache(t, 1<<20)
55 k := keyOf(t, 0)
56 var fetches int
57 f := func(ctx context.Context, w io.Writer) error { fetches++; return body("archive")(ctx, w) }
58 if hit, _, err := c.Ensure(context.Background(), k, f); err != nil || hit {
59 t.Fatalf("first: hit=%v err=%v", hit, err)
60 }
61 if hit, verify, err := c.Ensure(context.Background(), k, f); err != nil || !hit || verify <= 0 {
62 t.Fatalf("second: hit=%v err=%v", hit, err)
63 }
64 if fetches != 1 || read(t, c, k) != "archive" {
65 t.Fatalf("fetches=%d", fetches)
66 }
67 // A new process finds the entry.
68 c2, err := New(Config{Dir: c.dir, Max: 1 << 20})
69 if err != nil {
70 t.Fatal(err)
71 }
72 if hit, _, _ := c2.Ensure(context.Background(), k, f); !hit || fetches != 1 {
73 t.Fatalf("restart: hit=%v fetches=%d", hit, fetches)
74 }
75}
76
77func TestFailedFetchLeavesNothing(t *testing.T) {
78 c, _ := newCache(t, 1<<20)
79 k := keyOf(t, 0)
80 boom := errors.New("429")
81 _, _, err := c.Ensure(context.Background(), k, func(_ context.Context, w io.Writer) error { _, _ = io.WriteString(w, "part"); return boom })
82 if !errors.Is(err, boom) {
83 t.Fatal(err)
84 }
85 if _, err := c.Open(k); !errors.Is(err, ErrNotFound) {
86 t.Fatal("a failed download must not be an entry")
87 }
88 des, _ := os.ReadDir(c.dir)
89 if len(des) != 0 {
90 t.Fatalf("leftovers: %v", des)
91 }
92}
93
94func TestConcurrentSingleFlight(t *testing.T) {
95 c, _ := newCache(t, 1<<20)
96 k := keyOf(t, 0)
97 var fetches atomic.Int32
98 release := make(chan struct{})
99 f := func(ctx context.Context, w io.Writer) error {
100 fetches.Add(1)
101 <-release
102 return body("once")(ctx, w)
103 }
104 var wg sync.WaitGroup
105 var hits atomic.Int32
106 for range 8 {
107 wg.Add(1)
108 go func() {
109 defer wg.Done()
110 hit, _, err := c.Ensure(context.Background(), k, f)
111 if err != nil {
112 t.Error(err)
113 }
114 if hit {
115 hits.Add(1)
116 }
117 }()
118 }
119 time.Sleep(50 * time.Millisecond)
120 close(release)
121 wg.Wait()
122 if fetches.Load() != 1 {
123 t.Fatalf("%d downloads for one key", fetches.Load())
124 }
125 if hits.Load() != 7 {
126 t.Errorf("%d hits, want 7 (all but the downloader)", hits.Load())
127 }
128}
129
130func TestCorruptEntryIsDroppedAndRefetched(t *testing.T) {
131 c, _ := newCache(t, 1<<20)
132 k := keyOf(t, 0)
133 if _, _, err := c.Ensure(context.Background(), k, body("good bytes")); err != nil {
134 t.Fatal(err)
135 }
136 // Same size, other content: only the hash can tell.
137 if err := os.WriteFile(c.archivePath(k), []byte("evil bytes"), 0o600); err != nil {
138 t.Fatal(err)
139 }
140 if _, err := c.Open(k); !errors.Is(err, ErrNotFound) {
141 t.Fatalf("a corrupt entry was served: %v", err)
142 }
143 if c.Size() != 0 {
144 t.Errorf("size %d after the drop", c.Size())
145 }
146 hit, _, err := c.Ensure(context.Background(), k, body("good bytes"))
147 if err != nil || hit {
148 t.Fatalf("hit=%v err=%v", hit, err)
149 }
150 if read(t, c, k) != "good bytes" {
151 t.Fatal("not refetched")
152 }
153 // Truncation is caught too.
154 if err := os.WriteFile(c.archivePath(k), []byte("good"), 0o600); err != nil {
155 t.Fatal(err)
156 }
157 if _, err := c.Open(k); !errors.Is(err, ErrNotFound) {
158 t.Fatal("a truncated entry was served")
159 }
160}
161
162func TestLRUEviction(t *testing.T) {
163 c, now := newCache(t, 25)
164 ctx := context.Background()
165 step := func() { *now = now.Add(time.Hour + PinFor) } // past every pin
166 a, b, d := keyOf(t, 0), keyOf(t, 1), keyOf(t, 2)
167 for _, k := range []string{a, b} {
168 if _, _, err := c.Ensure(ctx, k, body(strings.Repeat("x", 10))); err != nil {
169 t.Fatal(err)
170 }
171 step()
172 }
173 // Use a again: b is now the least recently used.
174 if hit, _, _ := c.Ensure(ctx, a, body("unused")); !hit {
175 t.Fatal("a should hit")
176 }
177 step()
178 if _, _, err := c.Ensure(ctx, d, body(strings.Repeat("y", 10))); err != nil {
179 t.Fatal(err)
180 }
181 if _, err := c.Open(b); !errors.Is(err, ErrNotFound) {
182 t.Error("the least recently used entry survived")
183 }
184 for _, k := range []string{a, d} {
185 if _, err := c.Open(k); err != nil {
186 t.Errorf("%s evicted: %v", k[:6], err)
187 }
188 }
189 if c.Size() > 25 {
190 t.Errorf("size %d over the cap", c.Size())
191 }
192}
193
194// A pinned entry is not evicted, even to make room: the newcomer is the one
195// that is not cached, so the cap holds.
196func TestPinnedEntryIsNotEvicted(t *testing.T) {
197 c, _ := newCache(t, 15)
198 ctx := context.Background()
199 a, b := keyOf(t, 0), keyOf(t, 1)
200 if _, _, err := c.Ensure(ctx, a, body(strings.Repeat("x", 10))); err != nil {
201 t.Fatal(err)
202 }
203 if _, _, err := c.Ensure(ctx, b, body(strings.Repeat("y", 10))); !errors.Is(err, ErrTooLarge) {
204 t.Fatalf("got %v", err)
205 }
206 if _, err := c.Open(a); err != nil {
207 t.Fatalf("a pinned entry was evicted: %v", err)
208 }
209 if c.Size() > 15 {
210 t.Fatalf("size %d", c.Size())
211 }
212}
213
214// diskUse is the bytes of every file in the cache directory, temporary files
215// included.
216func diskUse(t *testing.T, c *Cache) int64 {
217 t.Helper()
218 var n int64
219 des, _ := os.ReadDir(c.dir)
220 for _, de := range des {
221 if fi, err := de.Info(); err == nil {
222 n += fi.Size()
223 }
224 }
225 return n
226}
227
228// Many downloads in flight at once, each below the cap, are charged as they
229// are written: the directory never holds more than the cap, temporary files
230// included, and the accounting returns to the bytes of the entries.
231func TestConcurrentDownloadsStayUnderTheCap(t *testing.T) {
232 const max = 100
233 c, _ := newCache(t, max)
234 var over atomic.Int32
235 var wg sync.WaitGroup
236 stop := make(chan struct{})
237 go func() {
238 for {
239 select {
240 case <-stop:
241 return
242 default:
243 // The charge covers the entries and the downloads in progress. (The
244 // directory cannot be sampled atomically: a listing taken while an
245 // entry is evicted and a download grows would count both.)
246 c.mu.Lock()
247 n := c.total + c.pending
248 c.mu.Unlock()
249 if n > max {
250 over.Add(1)
251 }
252 }
253 }
254 }()
255 for i := range 16 {
256 wg.Add(1)
257 go func() {
258 defer wg.Done()
259 k := keyOf(t, i)
260 _, _, _ = c.Ensure(context.Background(), k, func(_ context.Context, w io.Writer) error {
261 for range 6 { // 6 x 10 bytes, in pieces
262 if _, err := w.Write([]byte(strings.Repeat("z", 10))); err != nil {
263 return err
264 }
265 time.Sleep(time.Millisecond)
266 }
267 return nil
268 })
269 }()
270 }
271 wg.Wait()
272 close(stop)
273 if over.Load() != 0 {
274 t.Fatal("entries and downloads in flight went over the cap")
275 }
276 var onDisk int64
277 des, _ := os.ReadDir(c.dir)
278 for _, de := range des {
279 if strings.HasPrefix(de.Name(), tmpPrefix) {
280 t.Errorf("temporary file left: %s", de.Name())
281 }
282 if strings.HasSuffix(de.Name(), archiveExt) {
283 fi, _ := de.Info()
284 onDisk += fi.Size()
285 }
286 }
287 if onDisk > max {
288 t.Fatalf("%d bytes on disk", onDisk)
289 }
290 c.mu.Lock()
291 pending, total := c.pending, c.total
292 c.mu.Unlock()
293 if pending != 0 || total > max {
294 t.Fatalf("pending=%d total=%d", pending, total)
295 }
296 var sum int64
297 for _, e := range c.entries {
298 sum += e.size
299 }
300 if sum != total {
301 t.Fatalf("entries hold %d, accounted %d", sum, total)
302 }
303}
304
305// The bytes of a download in progress are counted, and a failed or refused one
306// leaves neither a file nor a charge.
307func TestTemporaryFilesAreCountedAndCleaned(t *testing.T) {
308 c, _ := newCache(t, 50)
309 seen := int64(-1)
310 _, _, err := c.Ensure(context.Background(), keyOf(t, 0), func(_ context.Context, w io.Writer) error {
311 _, _ = io.WriteString(w, strings.Repeat("a", 30))
312 c.mu.Lock()
313 seen = c.pending
314 c.mu.Unlock()
315 return errors.New("connection reset")
316 })
317 if err == nil || seen != 30 {
318 t.Fatalf("err=%v pending during=%d", err, seen)
319 }
320 // Refused midway for size: the part already written is released too.
321 _, _, err = c.Ensure(context.Background(), keyOf(t, 1), body(strings.Repeat("b", 60)))
322 if !errors.Is(err, ErrTooLarge) {
323 t.Fatalf("got %v", err)
324 }
325 if c.pending != 0 || c.Size() != 0 || diskUse(t, c) != 0 {
326 t.Fatalf("pending=%d size=%d disk=%d", c.pending, c.Size(), diskUse(t, c))
327 }
328 // The size is what was written, whatever the server announced.
329 if _, _, err := c.Ensure(context.Background(), keyOf(t, 2), body(strings.Repeat("c", 50))); err != nil {
330 t.Fatal(err)
331 }
332 if c.Size() != 50 {
333 t.Fatalf("size %d", c.Size())
334 }
335}
336
337func TestTooLarge(t *testing.T) {
338 c, _ := newCache(t, 10)
339 _, _, err := c.Ensure(context.Background(), keyOf(t, 0), body(strings.Repeat("x", 11)))
340 if !errors.Is(err, ErrTooLarge) {
341 t.Fatalf("got %v", err)
342 }
343 if c.Size() != 0 || diskUse(t, c) != 0 {
344 t.Fatalf("an oversize archive left %d bytes on disk", diskUse(t, c))
345 }
346}
347
348func TestStartDropsHalfWrittenFiles(t *testing.T) {
349 c, _ := newCache(t, 100)
350 k := keyOf(t, 0)
351 _ = os.WriteFile(c.archivePath(k), []byte("no sum"), 0o600)
352 _ = os.WriteFile(c.dir+"/tmp-123", []byte("x"), 0o600)
353 _ = os.WriteFile(c.sumPath(keyOf(t, 1)), []byte("sum without archive"), 0o600)
354 if _, err := New(Config{Dir: c.dir, Max: 100}); err != nil {
355 t.Fatal(err)
356 }
357 if des, _ := os.ReadDir(c.dir); len(des) != 0 {
358 t.Fatalf("leftovers: %v", des)
359 }
360}
361
362// The key is made of the forge, the repository and the commit, never a ref: a
363// tag or a branch name is not a sha and is refused, and two commits or two
364// forges never share an entry.
365func TestKeyNeverUsesARef(t *testing.T) {
366 sha := strings.Repeat("a", 40)
367 for _, bad := range []string{"", "v4", "main", "refs/tags/v4", strings.Repeat("g", 40), strings.Repeat("a", 39), strings.Repeat("a", 41)} {
368 if _, ok := Key("https://github.com", "o/r", bad); ok {
369 t.Errorf("%q made a key", bad)
370 }
371 }
372 k1, _ := Key("https://github.com", "o/r", sha)
373 k2, _ := Key("https://github.com", "o/r", strings.Repeat("b", 40))
374 k3, _ := Key("https://ermite.example", "o/r", sha)
375 k4, _ := Key("https://github.com", "O/R", strings.ToUpper(sha))
376 if k1 == k2 || k1 == k3 {
377 t.Error("collision")
378 }
379 if k1 != k4 {
380 t.Error("the case of the repository or the sha must not split an entry")
381 }
382 if !ValidKey(k1) {
383 t.Error("shape")
384 }
385}