Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 17m ago 4b8c9e1d
history blame raw 14.0 KiB
1package cache
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 "time"
9)
10
11// The namespace is the only thing separating one repository's cache from another's,
12// and an untrusted branch's from a protected one's, so the layout is a security
13// property and not an implementation detail.
14func TestNamespaceSeparatesReposAndTrust(t *testing.T) {
15 t.Parallel()
16 root := t.TempDir()
17 direct := Namespace(root, "demo", "direct", ImageKey("abc123", ""))
18 contrib := Namespace(root, "demo", "contrib", ImageKey("abc123", ""))
19 other := Namespace(root, "other", "direct", ImageKey("abc123", ""))
20
21 if direct == contrib || direct == other || contrib == other {
22 t.Fatalf("namespaces collide:\n%s\n%s\n%s", direct, contrib, other)
23 }
24 for _, ns := range []string{direct, contrib, other} {
25 if filepath.Dir(filepath.Dir(filepath.Dir(ns))) != root {
26 t.Fatalf("%s is not three levels under %s", ns, root)
27 }
28 }
29}
30
31// A definition's digest and a registry reference must not land on the same key,
32// and a reference full of slashes and colons must still be one path component.
33func TestImageKey(t *testing.T) {
34 t.Parallel()
35 a := ImageKey("0123456789abcdef0123", "")
36 if a != "file-0123456789abcdef" {
37 t.Fatalf("digest key = %q", a)
38 }
39 b := ImageKey("", "golang:1.27-bookworm")
40 c := ImageKey("", "golang:1.27-bookworm")
41 if b != c {
42 t.Fatalf("the same reference produced two keys: %q and %q", b, c)
43 }
44 if b != filepath.Base(b) {
45 t.Fatalf("key %q is not a single path component", b)
46 }
47 if d := ImageKey("", "golang:1.28-bookworm"); d == b {
48 t.Fatalf("two references produced the same key %q", b)
49 }
50}
51
52func TestParseSize(t *testing.T) {
53 t.Parallel()
54 for input, want := range map[string]int64{
55 "1": 1,
56 "512K": 512 << 10,
57 "512KiB": 512 << 10,
58 "100MB": 100 << 20,
59 "5G": 5 << 30,
60 "5GiB": 5 << 30,
61 "2T": 2 << 40,
62 " 64 M ": 64 << 20,
63 } {
64 got, err := ParseSize(input)
65 if err != nil || got != want {
66 t.Errorf("ParseSize(%q) = %d, %v; want %d", input, got, err, want)
67 }
68 }
69 for _, bad := range []string{"", "0", "-1", "five", "5X", "G", "1.5G"} {
70 if _, err := ParseSize(bad); err == nil {
71 t.Errorf("ParseSize(%q) was accepted", bad)
72 }
73 }
74}
75
76// A namespace is the eviction unit. Trimming must drop the least recently written
77// one entirely, including directories a compiler made read-only.
78func TestTrimDropsTheOldestNamespaceWhole(t *testing.T) {
79 t.Parallel()
80 root := t.TempDir()
81
82 mk := func(repo, image string, bytes int, age time.Duration, readOnly bool) string {
83 ns := Namespace(root, repo, "direct", image)
84 if err := os.MkdirAll(ns, 0o755); err != nil {
85 t.Fatal(err)
86 }
87 // Two files, one in a read-only directory: the shape a Go cache has.
88 sub := filepath.Join(ns, "locked")
89 if err := os.MkdirAll(sub, 0o755); err != nil {
90 t.Fatal(err)
91 }
92 for _, p := range []string{filepath.Join(ns, "a"), filepath.Join(sub, "b")} {
93 if err := os.WriteFile(p, make([]byte, bytes/2), 0o644); err != nil {
94 t.Fatal(err)
95 }
96 }
97 when := time.Now().Add(-age)
98 _ = os.Chtimes(filepath.Join(ns, "a"), when, when)
99 _ = os.Chtimes(filepath.Join(sub, "b"), when, when)
100 if readOnly {
101 if err := os.Chmod(sub, 0o555); err != nil {
102 t.Fatal(err)
103 }
104 // The namespace is expected to be removed by the test; if it is not, the
105 // failure is the assertion, not a cleanup error.
106 t.Cleanup(func() { _ = os.Chmod(sub, 0o755) })
107 }
108 return ns
109 }
110
111 old := mk("old", "ref-a", 1000, 2*time.Hour, true)
112 fresh := mk("fresh", "ref-b", 1000, time.Minute, false)
113
114 // Under the cap: nothing goes.
115 if removed, err := Trim(root, 4000); err != nil || removed != 0 {
116 t.Fatalf("trim under the cap = %d, %v", removed, err)
117 }
118 if _, err := os.Stat(old); err != nil {
119 t.Fatalf("a namespace was removed under the cap: %v", err)
120 }
121
122 // Half the cap: the older namespace goes, whole, read-only parts included.
123 if removed, err := Trim(root, 1500); err != nil || removed != 1000 {
124 t.Fatalf("trim = %d, %v; want 1000 bytes", removed, err)
125 }
126 if _, err := os.Stat(old); !os.IsNotExist(err) {
127 t.Fatalf("the oldest namespace survived: %v", err)
128 }
129 if _, err := os.Stat(fresh); err != nil {
130 t.Fatalf("the newest namespace was removed: %v", err)
131 }
132}
133
134// A namespace is aged by the work it holds, not by when its tree was unpacked.
135// Directories are created when a cache is extracted, so counting their mtimes let a
136// two-hour-old cache outlive a one-hour-old one. That is the shape of the failure
137// that made this test pass on ext4 and fail on a runner.
138func TestTrimOrdersByContentNotDirectories(t *testing.T) {
139 t.Parallel()
140 root := t.TempDir()
141
142 mk := func(repo, image string, content, dirs time.Time) string {
143 ns := Namespace(root, repo, "direct", image)
144 if err := os.MkdirAll(ns, 0o755); err != nil {
145 t.Fatal(err)
146 }
147 p := filepath.Join(ns, "a")
148 if err := os.WriteFile(p, make([]byte, 1000), 0o644); err != nil {
149 t.Fatal(err)
150 }
151 if err := os.Chtimes(p, content, content); err != nil {
152 t.Fatal(err)
153 }
154 if err := os.Chtimes(ns, dirs, dirs); err != nil {
155 t.Fatal(err)
156 }
157 return ns
158 }
159
160 now := time.Now()
161 old := mk("old", "ref-a", now.Add(-2*time.Hour), now)
162 fresh := mk("fresh", "ref-b", now.Add(-time.Hour), now.Add(-time.Hour))
163
164 if removed, err := Trim(root, 1500); err != nil || removed != 1000 {
165 t.Fatalf("trim = %d, %v; want 1000 bytes", removed, err)
166 }
167 if _, err := os.Stat(old); !os.IsNotExist(err) {
168 t.Fatalf("the older content survived because its directory was touched later: %v", err)
169 }
170 if _, err := os.Stat(fresh); err != nil {
171 t.Fatalf("the newer content was removed: %v", err)
172 }
173}
174
175// Two namespaces written at the same instant must not be ordered by chance: the same
176// one goes on every pass, so a runner sharing a directory does not evict a different
177// cache depending on which way the sort fell.
178func TestTrimBreaksATieDeterministically(t *testing.T) {
179 t.Parallel()
180 when := time.Now().Add(-time.Hour).Truncate(time.Second)
181 victim := ""
182 for run := 0; run < 20; run++ {
183 root := t.TempDir()
184 var built []string
185 for _, image := range []string{"ref-a", "ref-b"} {
186 ns := Namespace(root, "demo", "direct", image)
187 if err := os.MkdirAll(ns, 0o755); err != nil {
188 t.Fatal(err)
189 }
190 p := filepath.Join(ns, "a")
191 if err := os.WriteFile(p, make([]byte, 1000), 0o644); err != nil {
192 t.Fatal(err)
193 }
194 for _, path := range []string{p, ns} {
195 if err := os.Chtimes(path, when, when); err != nil {
196 t.Fatal(err)
197 }
198 }
199 built = append(built, ns)
200 }
201 if removed, err := Trim(root, 1500); err != nil || removed != 1000 {
202 t.Fatalf("run %d: trim = %d, %v; want 1000 bytes", run, removed, err)
203 }
204 gone := ""
205 for _, ns := range built {
206 if _, err := os.Stat(ns); os.IsNotExist(err) {
207 gone = ns
208 }
209 }
210 if gone == "" {
211 t.Fatalf("run %d: nothing was removed", run)
212 }
213 if victim == "" {
214 victim = gone
215 continue
216 }
217 // t.TempDir makes a new root per run, so compare the namespace that went,
218 // not where it was: the same name must be evicted every time.
219 if filepath.Base(gone) != filepath.Base(victim) {
220 t.Fatalf("run %d evicted %s, the first run evicted %s",
221 run, filepath.Base(gone), filepath.Base(victim))
222 }
223 }
224}
225
226// Clear is what --no-cache uses: the directory stays mounted, but nothing survives.
227func TestClearRemovesEverything(t *testing.T) {
228 t.Parallel()
229 root := t.TempDir()
230 for _, repo := range []string{"a", "b"} {
231 ns := Namespace(root, repo, "direct", "ref-x")
232 if err := os.MkdirAll(filepath.Join(ns, "locked"), 0o755); err != nil {
233 t.Fatal(err)
234 }
235 if err := os.WriteFile(filepath.Join(ns, "locked", "f"), []byte("x"), 0o644); err != nil {
236 t.Fatal(err)
237 }
238 if err := os.Chmod(filepath.Join(ns, "locked"), 0o555); err != nil {
239 t.Fatal(err)
240 }
241 t.Cleanup(func() { _ = os.Chmod(filepath.Join(ns, "locked"), 0o755) })
242 }
243 if err := Clear(root); err != nil {
244 t.Fatalf("clear: %v", err)
245 }
246 // The lock files under .locks are bookkeeping, not cache content: they stay,
247 // because removing one races with the next holder opening it. Everything a job
248 // could have written is gone.
249 var survived []string
250 for _, m := range globAll(t, filepath.Join(root, "*", "*", "*")) {
251 if !strings.Contains(m, string(filepath.Separator)+".locks"+string(filepath.Separator)) {
252 survived = append(survived, m)
253 }
254 }
255 if len(survived) != 0 {
256 t.Fatalf("namespaces survived Clear: %v", survived)
257 }
258}
259
260// globAll is filepath.Glob for a test, which turns a bad pattern into a failure
261// rather than a silently empty list.
262func globAll(t *testing.T, pattern string) []string {
263 t.Helper()
264 matches, err := filepath.Glob(pattern)
265 if err != nil {
266 t.Fatalf("glob %q: %v", pattern, err)
267 }
268 return matches
269}
270
271func TestTrimIsANoOpWithoutACap(t *testing.T) {
272 t.Parallel()
273 root := t.TempDir()
274 ns := Namespace(root, "demo", "direct", "ref-a")
275 if err := os.MkdirAll(ns, 0o755); err != nil {
276 t.Fatal(err)
277 }
278 if removed, err := Trim(root, 0); removed != 0 || err != nil {
279 t.Fatalf("trim with no cap = %d, %v", removed, err)
280 }
281 if _, err := os.Stat(ns); err != nil {
282 t.Fatalf("a namespace was removed with no cap set: %v", err)
283 }
284}
285
286// writeNamespace fills a namespace with one file of the given size and stamps it
287// with an age, which is what Trim sorts by.
288func writeNamespace(t *testing.T, ns string, bytes int, age time.Duration) {
289 t.Helper()
290 if err := os.MkdirAll(ns, 0o755); err != nil {
291 t.Fatal(err)
292 }
293 file := filepath.Join(ns, "content")
294 if err := os.WriteFile(file, make([]byte, bytes), 0o644); err != nil {
295 t.Fatal(err)
296 }
297 when := time.Now().Add(-age)
298 if err := os.Chtimes(file, when, when); err != nil {
299 t.Fatal(err)
300 }
301}
302
303// A job holds its namespace while it runs, so a trim that reaches it must move on
304// to the next candidate rather than delete a module cache out from under a build.
305func TestTrimSkipsANamespaceInUse(t *testing.T) {
306 t.Parallel()
307 root := t.TempDir()
308 old := Namespace(root, "old", "direct", "ref-a")
309 fresh := Namespace(root, "fresh", "direct", "ref-b")
310 writeNamespace(t, old, 1000, 2*time.Hour)
311 writeNamespace(t, fresh, 1000, time.Minute)
312
313 release, err := Lock(root, old)
314 if err != nil {
315 t.Fatalf("lock: %v", err)
316 }
317
318 // The cap wants 500 bytes gone. The oldest namespace is the one in use, so the
319 // trimmer takes the next one instead.
320 removed, err := Trim(root, 1500)
321 if err != nil || removed != 1000 {
322 t.Fatalf("trim = %d, %v; want 1000 bytes from the newest namespace", removed, err)
323 }
324 if _, err := os.Stat(old); err != nil {
325 t.Fatalf("a mounted namespace was removed: %v", err)
326 }
327 if _, err := os.Stat(fresh); !os.IsNotExist(err) {
328 t.Fatalf("the trimmer did not fall through to the next namespace: %v", err)
329 }
330
331 // Once the job is done the namespace is a candidate again.
332 release()
333 if removed, err := Trim(root, 500); err != nil || removed != 1000 {
334 t.Fatalf("trim after release = %d, %v; want 1000 bytes", removed, err)
335 }
336 if _, err := os.Stat(old); !os.IsNotExist(err) {
337 t.Fatalf("the namespace survived once nobody held it: %v", err)
338 }
339}
340
341// Clear is what --no-cache uses. It empties what nobody has mounted and leaves a
342// build alone, which is the difference between a fresh cache and a broken one.
343func TestClearSkipsANamespaceInUse(t *testing.T) {
344 t.Parallel()
345 root := t.TempDir()
346 mine := Namespace(root, "mine", "direct", "ref-a")
347 theirs := Namespace(root, "theirs", "direct", "ref-b")
348 writeNamespace(t, mine, 10, time.Minute)
349 writeNamespace(t, theirs, 10, time.Minute)
350
351 release, err := Lock(root, theirs)
352 if err != nil {
353 t.Fatalf("lock: %v", err)
354 }
355 if err := Clear(root); err != nil {
356 t.Fatalf("clear: %v", err)
357 }
358 if _, err := os.Stat(mine); !os.IsNotExist(err) {
359 t.Fatalf("Clear left an unused namespace behind: %v", err)
360 }
361 if _, err := os.Stat(theirs); err != nil {
362 t.Fatalf("Clear emptied a namespace a job has mounted: %v", err)
363 }
364
365 release()
366 if err := Clear(root); err != nil {
367 t.Fatalf("clear after release: %v", err)
368 }
369 if _, err := os.Stat(theirs); !os.IsNotExist(err) {
370 t.Fatalf("the namespace survived once nobody held it: %v", err)
371 }
372}
373
374// The lock file lives beside the namespace, not inside it: removing a namespace
375// must not remove the lock its holder is using, and the sweep must not mistake a
376// lock for a cache.
377func TestTheLockFileOutlivesItsNamespace(t *testing.T) {
378 t.Parallel()
379 root := t.TempDir()
380 ns := Namespace(root, "demo", "direct", "ref-a")
381 writeNamespace(t, ns, 10, time.Minute)
382
383 lock, err := lockPath(root, ns)
384 if err != nil {
385 t.Fatalf("lock path: %v", err)
386 }
387 if strings.HasPrefix(lock, ns+string(filepath.Separator)) {
388 t.Fatalf("the lock %q is inside the namespace it protects", lock)
389 }
390
391 release, err := Lock(root, ns)
392 if err != nil {
393 t.Fatalf("lock: %v", err)
394 }
395 release()
396 if _, err := os.Stat(lock); err != nil {
397 t.Fatalf("no lock file beside the namespace: %v", err)
398 }
399
400 if removed, err := Trim(root, 1); err != nil || removed != 10 {
401 t.Fatalf("trim = %d, %v; want 10 bytes", removed, err)
402 }
403 if _, err := os.Stat(lock); err != nil {
404 t.Fatalf("trimming the namespace removed the lock beside it: %v", err)
405 }
406 // Removing a lock file races with the next holder opening it, so it stays; a
407 // later job has to be able to take it.
408 again, err := Lock(root, ns)
409 if err != nil {
410 t.Fatalf("lock after the namespace was evicted: %v", err)
411 }
412 again()
413}
414
415// Two jobs and a trimmer on one root: neither holder may lose its tree, however
416// often the disk-pressure check runs.
417func TestATrimmerNeverTakesANamespaceInUse(t *testing.T) {
418 t.Parallel()
419 root := t.TempDir()
420 first := Namespace(root, "one", "direct", "ref-a")
421 second := Namespace(root, "two", "direct", "ref-b")
422 writeNamespace(t, first, 100, time.Minute)
423 writeNamespace(t, second, 100, time.Minute)
424
425 releaseFirst, err := Lock(root, first)
426 if err != nil {
427 t.Fatalf("lock: %v", err)
428 }
429 defer releaseFirst()
430 releaseSecond, err := Lock(root, second)
431 if err != nil {
432 t.Fatalf("lock: %v", err)
433 }
434 defer releaseSecond()
435
436 done := make(chan struct{})
437 go func() {
438 defer close(done)
439 for i := 0; i < 20; i++ {
440 // A cap that wants everything gone: every pass has to skip both.
441 if _, err := Trim(root, 1); err != nil {
442 return
443 }
444 }
445 }()
446 <-done
447
448 for _, ns := range []string{first, second} {
449 if _, err := os.Stat(ns); err != nil {
450 t.Fatalf("the trimmer removed a mounted namespace: %v", err)
451 }
452 }
453}
454