1// SPDX-License-Identifier: MIT
2
3package externals
4
5import (
6 "archive/tar"
7 "bytes"
8 "compress/gzip"
9 "context"
10 "crypto/sha256"
11 "encoding/hex"
12 "fmt"
13 "net/http"
14 "net/http/httptest"
15 "net/url"
16 "os"
17 "os/exec"
18 "path/filepath"
19 "regexp"
20 "strings"
21 "sync"
22 "sync/atomic"
23 "syscall"
24 "testing"
25 "time"
26
27 "github.com/bnema/zerowrap"
28
29 "bnema.dev/ermite-runner/internal/sandbox"
30)
31
32// ---- the table
33
34func TestPinnedTable(t *testing.T) {
35 hex64 := regexp.MustCompile(`^[0-9a-f]{64}$`)
36 seen := map[string]bool{}
37 for _, a := range Pinned {
38 key := a.Name + "/" + a.Arch
39 if seen[key] {
40 t.Errorf("%s listed twice", key)
41 }
42 seen[key] = true
43 if !strings.HasPrefix(a.URL, "https://") || !strings.HasSuffix(a.URL, ".tar.gz") || !strings.Contains(a.URL, "v"+a.Version+"/") {
44 t.Errorf("%s: URL %q", key, a.URL)
45 }
46 if !hex64.MatchString(a.SHA256) {
47 t.Errorf("%s: SHA-256 %q", key, a.SHA256)
48 }
49 if a.StripTop != strings.HasPrefix(a.URL, nodeDist) {
50 t.Errorf("%s: StripTop %v for %s", key, a.StripTop, a.URL)
51 }
52 }
53 // actions/runner's externals.sh: node20 and node24 for both architectures,
54 // the alpine builds for x64.
55 for _, k := range []string{"node20/x64", "node20/arm64", "node24/x64", "node24/arm64", "node20_alpine/x64", "node24_alpine/x64"} {
56 if !seen[k] {
57 t.Errorf("%s missing", k)
58 }
59 }
60 for _, a := range Pinned {
61 if strings.HasPrefix(a.Name, "node20") && a.Version != "20.20.2" || strings.HasPrefix(a.Name, "node24") && a.Version != "24.21.0" {
62 t.Errorf("%s pins %s", a.Name, a.Version)
63 }
64 }
65 for _, a := range Pinned {
66 if strings.HasSuffix(a.Name, "_alpine") && (a.Arch != "x64" || !strings.HasPrefix(a.URL, alpineDist+"v"+a.Version+"/node-v"+a.Version+"-alpine-x64.tar.gz")) {
67 t.Errorf("%s: %+v", a.Name, a)
68 }
69 }
70 if got := len(ForArch(Pinned, "arm64")); got != 2 {
71 t.Errorf("arm64 has %d artifacts", got)
72 }
73}
74
75func TestVerifySum(t *testing.T) {
76 if err := VerifySum("AB12", "ab12"); err != nil {
77 t.Error(err)
78 }
79 if err := VerifySum("ab12", "ab13"); err == nil || !strings.Contains(err.Error(), "mismatch") {
80 t.Errorf("got %v", err)
81 }
82}
83
84// ---- archives
85
86type ent struct {
87 name, link string
88 typ byte
89 mode int64
90 body string
91}
92
93func targz(t *testing.T, ents ...ent) []byte {
94 t.Helper()
95 var buf bytes.Buffer
96 gz := gzip.NewWriter(&buf)
97 tw := tar.NewWriter(gz)
98 for _, e := range ents {
99 typ := e.typ
100 if typ == 0 {
101 typ = tar.TypeReg
102 }
103 h := &tar.Header{Name: e.name, Linkname: e.link, Typeflag: typ, Mode: e.mode, Size: int64(len(e.body))}
104 if typ != tar.TypeReg {
105 h.Size = 0
106 }
107 if err := tw.WriteHeader(h); err != nil {
108 t.Fatal(err)
109 }
110 if typ == tar.TypeReg {
111 if _, err := tw.Write([]byte(e.body)); err != nil {
112 t.Fatal(err)
113 }
114 }
115 }
116 if err := tw.Close(); err != nil {
117 t.Fatal(err)
118 }
119 if err := gz.Close(); err != nil {
120 t.Fatal(err)
121 }
122 return buf.Bytes()
123}
124
125func nodeTree() []ent {
126 return []ent{
127 {name: "node-v1-linux-x64/", typ: tar.TypeDir, mode: 0o755},
128 {name: "node-v1-linux-x64/bin/", typ: tar.TypeDir, mode: 0o755},
129 {name: "node-v1-linux-x64/bin/node", mode: 0o755, body: "#!/bin/sh\necho v1\n"},
130 {name: "node-v1-linux-x64/lib/node_modules/npm/bin/npm-cli.js", mode: 0o644, body: "x"},
131 {name: "node-v1-linux-x64/bin/npm", typ: tar.TypeSymlink, link: "../lib/node_modules/npm/bin/npm-cli.js", mode: 0o777},
132 {name: "node-v1-linux-x64/bin/nodejs", typ: tar.TypeLink, link: "node-v1-linux-x64/bin/node", mode: 0o755},
133 }
134}
135
136func extractBytes(t *testing.T, data []byte) (string, error) {
137 t.Helper()
138 dir := t.TempDir()
139 arch := filepath.Join(dir, "a.tar.gz")
140 if err := os.WriteFile(arch, data, 0o600); err != nil {
141 t.Fatal(err)
142 }
143 dst := filepath.Join(dir, "out")
144 if err := os.Mkdir(dst, 0o755); err != nil {
145 t.Fatal(err)
146 }
147 return dst, extractTarGz(arch, dst, true)
148}
149
150func TestExtractLayoutAndModes(t *testing.T) {
151 dst, err := extractBytes(t, targz(t, nodeTree()...))
152 if err != nil {
153 t.Fatal(err)
154 }
155 st, err := os.Stat(filepath.Join(dst, "bin", "node"))
156 if err != nil || st.Mode().Perm() != 0o755 {
157 t.Fatalf("bin/node: %v %v", st, err)
158 }
159 if st, _ := os.Stat(filepath.Join(dst, "lib/node_modules/npm/bin/npm-cli.js")); st == nil || st.Mode().Perm() != 0o644 {
160 t.Fatalf("npm-cli.js: %v", st)
161 }
162 if l, err := os.Readlink(filepath.Join(dst, "bin", "npm")); err != nil || l != "../lib/node_modules/npm/bin/npm-cli.js" {
163 t.Fatalf("npm link: %q %v", l, err)
164 }
165 a, _ := os.Stat(filepath.Join(dst, "bin", "node"))
166 b, _ := os.Stat(filepath.Join(dst, "bin", "nodejs"))
167 if !os.SameFile(a, b) {
168 t.Fatal("hard link not recreated")
169 }
170 if !isReady(dst) {
171 t.Fatal("not ready")
172 }
173}
174
175func TestModesDoNotDependOnTheUmask(t *testing.T) {
176 old := syscall.Umask(0o077)
177 defer syscall.Umask(old)
178 dst, err := extractBytes(t, targz(t, nodeTree()...))
179 if err != nil {
180 t.Fatal(err)
181 }
182 for p, want := range map[string]os.FileMode{
183 dst: 0o755, filepath.Join(dst, "bin"): 0o755, filepath.Join(dst, "lib/node_modules"): 0o755,
184 filepath.Join(dst, "bin/node"): 0o755, filepath.Join(dst, "lib/node_modules/npm/bin/npm-cli.js"): 0o644,
185 } {
186 if st, err := os.Stat(p); err != nil || st.Mode().Perm() != want {
187 t.Errorf("%s: %v %v, want %v", p, st, err, want)
188 }
189 }
190 m := &Manager{Dir: filepath.Join(t.TempDir(), "e"), Arch: "x64"}
191 e := m.unavailable("node24", "why", false)
192 for p, want := range map[string]os.FileMode{
193 filepath.Dir(e.ext.Dir): 0o755, e.ext.Dir: 0o755, filepath.Join(e.ext.Dir, "bin"): 0o755,
194 filepath.Join(e.ext.Dir, "bin/node"): 0o755, filepath.Join(e.ext.Dir, "UNAVAILABLE"): 0o644,
195 } {
196 if st, err := os.Stat(p); err != nil || st.Mode().Perm() != want {
197 t.Errorf("%s: %v %v, want %v", p, st, err, want)
198 }
199 }
200}
201
202func TestExtractRefusesUnsafeArchives(t *testing.T) {
203 top := "node-v1-linux-x64/"
204 for name, ents := range map[string][]ent{
205 "absolute path": {{name: "/etc/passwd", body: "x"}},
206 "dot dot": {{name: top + "../../evil", body: "x"}},
207 "dot dot in middle": {{name: top + "a/../../evil", body: "x"}},
208 "backslash": {{name: top + `a\b`, body: "x"}},
209 "absolute symlink": {{name: top + "l", typ: tar.TypeSymlink, link: "/etc"}},
210 "escaping symlink": {{name: top + "l", typ: tar.TypeSymlink, link: "../../.."}},
211 "escaping from deep": {{name: top + "a/b/l", typ: tar.TypeSymlink, link: "../../../x"}},
212 "climb after a name": {{name: top + "a/l", typ: tar.TypeSymlink, link: "b/../../../x"}},
213 "empty link target": {{name: top + "l", typ: tar.TypeSymlink, link: ""}},
214 "write through link": {
215 {name: top + "d", typ: tar.TypeSymlink, link: "."},
216 {name: top + "d/file", body: "x"},
217 },
218 "write through dir link": {
219 {name: top + "sub/", typ: tar.TypeDir},
220 {name: top + "sub/up", typ: tar.TypeSymlink, link: ".."},
221 {name: top + "sub/up/file", body: "x"},
222 },
223 "hard link outside": {{name: top + "h", typ: tar.TypeLink, link: "/etc/passwd"}},
224 "hard link dot dot": {{name: top + "h", typ: tar.TypeLink, link: top + "../x"}},
225 "hard link to top": {{name: top + "h", typ: tar.TypeLink, link: top}},
226 "hard link to a link": {
227 {name: top + "l", typ: tar.TypeSymlink, link: "x"},
228 {name: top + "h", typ: tar.TypeLink, link: top + "l"},
229 },
230 "device": {{name: top + "dev", typ: tar.TypeChar}},
231 "fifo": {{name: top + "fifo", typ: tar.TypeFifo}},
232 "duplicate": {{name: top + "f", body: "1"}, {name: top + "f", body: "2"}},
233 } {
234 t.Run(name, func(t *testing.T) {
235 dst, err := extractBytes(t, targz(t, ents...))
236 if err == nil {
237 t.Fatal("accepted")
238 }
239 // Nothing was written outside the destination, whatever the order.
240 parent := filepath.Dir(dst)
241 if _, err := os.Stat(filepath.Join(parent, "evil")); err == nil {
242 t.Fatal("wrote outside")
243 }
244 if _, err := os.Stat(filepath.Join(dst, "..", "file")); err == nil {
245 t.Fatal("wrote outside through a link")
246 }
247 })
248 }
249}
250
251func TestExtractRefusesGarbageAndTooMany(t *testing.T) {
252 if _, err := extractBytes(t, []byte("not gzip")); err == nil {
253 t.Error("garbage accepted")
254 }
255 var ents []ent
256 for i := 0; i < maxEntries+1; i++ {
257 ents = append(ents, ent{name: "t/d/", typ: tar.TypeDir})
258 }
259 if _, err := extractBytes(t, targz(t, ents...)); err == nil || !strings.Contains(err.Error(), "entries") {
260 t.Errorf("entry limit: %v", err)
261 }
262}
263
264func TestLinkTargetRule(t *testing.T) {
265 for _, c := range []struct {
266 name, target string
267 ok bool
268 }{
269 {"bin/npm", "../lib/x", true},
270 {"bin/npm", "../../x", false}, // one above the tree
271 {"lib/a/b/l", "../../../x", true},
272 {"lib/a/b/l", "../../../../x", false},
273 {"l", "bin/node", true},
274 {"l", "../x", false},
275 {"l", "/abs", false},
276 {"a/l", "b/../c", false},
277 } {
278 if err := checkLinkTarget(c.name, c.target); (err == nil) != c.ok {
279 t.Errorf("%s -> %s: %v", c.name, c.target, err)
280 }
281 }
282}
283
284// ---- the manager
285
286type fixture struct {
287 srv *httptest.Server
288 hits atomic.Int64
289 archive []byte
290 sum string
291 archiveAlpine []byte // no top directory, like actions/alpine_nodejs
292 sumAlpine string
293 table []Artifact
294 dir string
295 m *Manager
296}
297
298func newFixture(t *testing.T) *fixture {
299 t.Helper()
300 f := &fixture{}
301 f.archive = targz(t, nodeTree()...)
302 s := sha256.Sum256(f.archive)
303 f.sum = hex.EncodeToString(s[:])
304 f.archiveAlpine = targz(t,
305 ent{name: "./bin/", typ: tar.TypeDir, mode: 0o755},
306 ent{name: "./bin/node", mode: 0o755, body: "#!/bin/sh\necho alpine\n"},
307 ent{name: "./LICENSE", mode: 0o644, body: "x"})
308 sa := sha256.Sum256(f.archiveAlpine)
309 f.sumAlpine = hex.EncodeToString(sa[:])
310 f.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
311 f.hits.Add(1)
312 switch {
313 case strings.HasSuffix(r.URL.Path, "/alpine.tar.gz"):
314 _, _ = w.Write(f.archiveAlpine)
315 case strings.HasSuffix(r.URL.Path, "-linux-x64.tar.gz"):
316 _, _ = w.Write(f.archive)
317 case strings.HasSuffix(r.URL.Path, "tampered.tar.gz"):
318 _, _ = w.Write(append([]byte(nil), f.archive[:len(f.archive)-1]...))
319 default:
320 http.NotFound(w, r)
321 }
322 }))
323 t.Cleanup(f.srv.Close)
324 f.dir = filepath.Join(t.TempDir(), "externals")
325 f.table = []Artifact{
326 {Name: "node20", Version: "1.0.0", Arch: "x64", URL: f.srv.URL + "/v1.0.0/node-v1.0.0-linux-x64.tar.gz", SHA256: f.sum, StripTop: true},
327 {Name: "node24", Version: "2.0.0", Arch: "x64", URL: f.srv.URL + "/v2.0.0/node-v2.0.0-linux-x64.tar.gz", SHA256: f.sum, StripTop: true},
328 {Name: "node20_alpine", Version: "1.0.0", Arch: "x64", URL: f.srv.URL + "/v1.0.0/alpine.tar.gz", SHA256: f.sumAlpine},
329 }
330 f.m = &Manager{Dir: f.dir, Arch: "x64", Table: f.table, Client: f.srv.Client()}
331 return f
332}
333
334func byName(set []sandbox.External) map[string]sandbox.External {
335 m := map[string]sandbox.External{}
336 for _, e := range set {
337 m[e.Name] = e
338 }
339 return m
340}
341
342func TestSyncDownloadsVerifiesAndReuses(t *testing.T) {
343 f := newFixture(t)
344 if err := f.m.Sync(context.Background()); err != nil {
345 t.Fatal(err)
346 }
347 set := byName(f.m.Externals())
348 n20, n24, al := set["node20"], set["node24"], set["node20_alpine"]
349 if n20.Unavailable || n24.Unavailable || n20.Dir != filepath.Join(f.dir, "node20-1.0.0-x64") {
350 t.Fatalf("set: %+v", set)
351 }
352 if n20.ID == n24.ID {
353 t.Error("two tools share an ID")
354 }
355 if b, err := os.ReadFile(filepath.Join(n24.Dir, "bin", "node")); err != nil || !strings.Contains(string(b), "echo v1") {
356 t.Fatalf("node24: %q %v", b, err)
357 }
358 // The alpine archive has no top directory: bin/node is at its root.
359 if al.Unavailable {
360 t.Fatalf("alpine: %+v", al)
361 }
362 if b, err := os.ReadFile(filepath.Join(al.Dir, "bin", "node")); err != nil || !strings.Contains(string(b), "echo alpine") {
363 t.Fatalf("alpine node: %q %v", b, err)
364 }
365 hits := f.hits.Load()
366 if hits != 3 {
367 t.Fatalf("downloads: %d", hits)
368 }
369
370 // A second start finds everything in place and downloads nothing.
371 m2 := &Manager{Dir: f.dir, Arch: "x64", Table: f.table, Client: f.srv.Client()}
372 if err := m2.Sync(context.Background()); err != nil {
373 t.Fatal(err)
374 }
375 if f.hits.Load() != hits {
376 t.Fatal("downloaded again")
377 }
378 if got := byName(m2.Externals())["node24"]; got.ID != n24.ID {
379 t.Fatalf("ID changed: %q -> %q", n24.ID, got.ID)
380 }
381}
382
383// runStub runs the bin/node of an unavailable tool: it must fail with the reason
384// on stderr, from wherever the tool is mounted.
385func runStub(t *testing.T, e sandbox.External, want string) {
386 t.Helper()
387 if _, err := exec.LookPath("sh"); err != nil {
388 t.Skip("sh needed")
389 }
390 // Run it through a symlinked path too, as a guest sees it at another place.
391 cmd := exec.Command(filepath.Join(e.Dir, "bin", "node"), "-e", "x")
392 var stderr, stdout bytes.Buffer
393 cmd.Stderr, cmd.Stdout = &stderr, &stdout
394 err := cmd.Run()
395 if ee, ok := err.(*exec.ExitError); !ok || ee.ExitCode() != 1 {
396 t.Fatalf("the stub must exit 1, got %v", err)
397 }
398 if !strings.Contains(stderr.String(), want) || stdout.Len() != 0 {
399 t.Fatalf("stub stderr %q stdout %q, want %q", stderr.String(), stdout.String(), want)
400 }
401 if b, _ := os.ReadFile(filepath.Join(e.Dir, "bin", "node")); strings.Contains(string(b), want) {
402 t.Fatalf("the stub holds the reason: %q", b)
403 }
404}
405
406func TestSyncRefusesAChecksumMismatchBeforeExtracting(t *testing.T) {
407 f := newFixture(t)
408 f.table[0].SHA256 = strings.Repeat("0", 64)
409 f.m.Table = f.table
410 err := f.m.Sync(context.Background())
411 if err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") {
412 t.Fatalf("got %v", err)
413 }
414 n20 := byName(f.m.Externals())["node20"]
415 if !n20.Unavailable {
416 t.Fatal("a tool with a wrong checksum is available")
417 }
418 if _, err := os.Stat(filepath.Join(f.dir, "node20-1.0.0-x64")); err == nil {
419 t.Fatal("extracted although the checksum differs")
420 }
421 reason, _ := os.ReadFile(filepath.Join(n20.Dir, sandbox.ExternalUnavailableFile))
422 if !strings.Contains(string(reason), "SHA-256 mismatch") {
423 t.Fatalf("reason: %q", reason)
424 }
425 // The other tool is unaffected.
426 if byName(f.m.Externals())["node24"].Unavailable {
427 t.Fatal("node24 lost to node20's failure")
428 }
429 // A checksum failure is a verdict: no retry is scheduled for it.
430 if f.m.state["node20"].retry {
431 t.Fatal("retry scheduled for a mismatch")
432 }
433 runStub(t, n20, "SHA-256 mismatch")
434 ents, _ := os.ReadDir(f.dir)
435 for _, e := range ents {
436 if strings.HasPrefix(e.Name(), ".tmp-") {
437 t.Fatalf("leftover %s", e.Name())
438 }
439 }
440}
441
442func TestSyncRefusesAnUnsafeArchiveWithTheRightChecksum(t *testing.T) {
443 f := newFixture(t)
444 bad := targz(t, ent{name: "node-v1-linux-x64/bin/node", mode: 0o755, body: "x"}, ent{name: "node-v1-linux-x64/l", typ: tar.TypeSymlink, link: "/etc"})
445 f.archive = bad
446 s := sha256.Sum256(bad)
447 f.table[0].SHA256, f.table[1].SHA256 = hex.EncodeToString(s[:]), hex.EncodeToString(s[:])
448 f.m.Table = f.table
449 err := f.m.Sync(context.Background())
450 if err == nil || !strings.Contains(err.Error(), "absolute") {
451 t.Fatalf("got %v", err)
452 }
453 if _, err := os.Stat(filepath.Join(f.dir, "node20-1.0.0-x64")); err == nil {
454 t.Fatal("unsafe archive left in place")
455 }
456}
457
458func TestSyncOfflineKeepsTheRunnerGoing(t *testing.T) {
459 f := newFixture(t)
460 f.srv.Close() // offline
461 err := f.m.Sync(context.Background())
462 if err == nil {
463 t.Fatal("no error offline")
464 }
465 for _, n := range []string{"node20", "node24"} {
466 e := byName(f.m.Externals())[n]
467 if !e.Unavailable {
468 t.Fatalf("%s available offline", n)
469 }
470 runStub(t, e, "is not available on this runner")
471 }
472 if !f.m.wantsRetry() {
473 t.Fatal("a network failure must be retried")
474 }
475}
476
477func TestBadStatusAndRedirectToHTTP(t *testing.T) {
478 f := newFixture(t)
479 f.table[0].URL = f.srv.URL + "/missing.tar.gz"
480 f.m.Table = f.table
481 if err := f.m.Sync(context.Background()); err == nil || !strings.Contains(err.Error(), "404") {
482 t.Fatalf("got %v", err)
483 }
484 if !byName(f.m.Externals())["node20"].Unavailable {
485 t.Fatal("404 gives a tool")
486 }
487
488 // The default client refuses a redirect to plain http, and too many.
489 check := newClient().CheckRedirect
490 if err := check(&http.Request{URL: mustURL(t, "http://example.org/x")}, nil); err == nil {
491 t.Error("redirect to http followed")
492 }
493 if err := check(&http.Request{URL: mustURL(t, "https://example.org/x")}, make([]*http.Request, 5)); err == nil {
494 t.Error("redirect loop followed")
495 }
496 if err := check(&http.Request{URL: mustURL(t, "https://example.org/x")}, make([]*http.Request, 1)); err != nil {
497 t.Error(err)
498 }
499 // A plain http URL in the table is refused outright.
500 m2 := &Manager{Dir: filepath.Join(t.TempDir(), "e"), Arch: "x64", Client: f.srv.Client()}
501 m2.Table = []Artifact{{Name: "node20", Version: "1", Arch: "x64", URL: "http://example.invalid/a.tar.gz", SHA256: f.sum}}
502 if err := m2.Sync(context.Background()); err == nil || !strings.Contains(err.Error(), "not an https URL") {
503 t.Fatalf("got %v", err)
504 }
505}
506
507func mustURL(t *testing.T, s string) *url.URL {
508 t.Helper()
509 u, err := url.Parse(s)
510 if err != nil {
511 t.Fatal(err)
512 }
513 return u
514}
515
516func TestSyncKeepsOnlyPinnedVersions(t *testing.T) {
517 f := newFixture(t)
518 if err := f.m.Sync(context.Background()); err != nil {
519 t.Fatal(err)
520 }
521 // Leftovers: an old version, an interrupted download, a stray file, an
522 // incomplete directory under a pinned name.
523 for _, d := range []string{"node20-0.9.0-x64", ".tmp-123", "node24-2.0.0-arm64", "node-old"} {
524 if err := os.MkdirAll(filepath.Join(f.dir, d, "bin"), 0o755); err != nil {
525 t.Fatal(err)
526 }
527 }
528 if err := os.WriteFile(filepath.Join(f.dir, "stray"), []byte("x"), 0o644); err != nil {
529 t.Fatal(err)
530 }
531 if err := f.m.Sync(context.Background()); err != nil {
532 t.Fatal(err)
533 }
534 var got []string
535 ents, _ := os.ReadDir(f.dir)
536 for _, e := range ents {
537 got = append(got, e.Name())
538 }
539 want := "node20-1.0.0-x64 node20_alpine-1.0.0-x64 node24-2.0.0-x64"
540 if strings.Join(got, " ") != want {
541 t.Fatalf("kept %v, want %s", got, want)
542 }
543}
544
545func TestSyncRebuildsAnIncompleteDirectory(t *testing.T) {
546 f := newFixture(t)
547 final := filepath.Join(f.dir, "node20-1.0.0-x64")
548 if err := os.MkdirAll(filepath.Join(final, "bin"), 0o755); err != nil { // no bin/node
549 t.Fatal(err)
550 }
551 if err := f.m.Sync(context.Background()); err != nil {
552 t.Fatal(err)
553 }
554 if !isReady(final) {
555 t.Fatal("incomplete directory kept")
556 }
557}
558
559func TestANewVersionChangesTheIDAndDropsTheOldDirectory(t *testing.T) {
560 f := newFixture(t)
561 if err := f.m.Sync(context.Background()); err != nil {
562 t.Fatal(err)
563 }
564 old := byName(f.m.Externals())["node24"]
565 f.table[1].Version = "2.1.0"
566 f.table[1].URL = f.srv.URL + "/v2.1.0/node-v2.1.0-linux-x64.tar.gz"
567 if err := f.m.Sync(context.Background()); err != nil {
568 t.Fatal(err)
569 }
570 n := byName(f.m.Externals())["node24"]
571 if n.ID == old.ID || n.Dir == old.Dir {
572 t.Fatalf("same tool after an upgrade: %+v %+v", old, n)
573 }
574 if _, err := os.Stat(old.Dir); err == nil {
575 t.Fatal("old version kept")
576 }
577}
578
579func TestNoBuildForTheArchitecture(t *testing.T) {
580 f := newFixture(t)
581 f.m.Arch = "arm64" // the fixture table is x64 only
582 if err := f.m.Sync(context.Background()); err != nil {
583 t.Fatal(err)
584 }
585 for _, e := range f.m.Externals() {
586 reason, _ := os.ReadFile(filepath.Join(e.Dir, sandbox.ExternalUnavailableFile))
587 if !e.Unavailable || !strings.Contains(string(reason), "no "+e.Name+" build for linux-arm64") {
588 t.Errorf("%s: %+v %q", e.Name, e, reason)
589 }
590 }
591 if f.hits.Load() != 0 {
592 t.Error("downloaded for the wrong architecture")
593 }
594}
595
596func TestBeforeTheFirstSyncTheToolsAreBeingDownloaded(t *testing.T) {
597 f := newFixture(t)
598 set := f.m.Externals()
599 if len(set) != 3 {
600 t.Fatalf("set: %+v", set)
601 }
602 for _, e := range set {
603 reason, _ := os.ReadFile(filepath.Join(e.Dir, sandbox.ExternalUnavailableFile))
604 if !e.Unavailable || !strings.Contains(string(reason), "still being downloaded") {
605 t.Errorf("%s: %q", e.Name, reason)
606 }
607 }
608}
609
610func TestFailedDownloadIsRetriedInTheBackground(t *testing.T) {
611 f := newFixture(t)
612 var up atomic.Bool
613 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
614 if !up.Load() {
615 http.Error(w, "down", http.StatusServiceUnavailable)
616 return
617 }
618 _, _ = w.Write(f.archive)
619 }))
620 defer srv.Close()
621 m := &Manager{Dir: f.dir, Arch: "x64", Client: srv.Client(), RetryEvery: time.Millisecond}
622 m.Table = []Artifact{{Name: "node24", Version: "2.0.0", Arch: "x64", URL: srv.URL + "/a-linux-x64.tar.gz", SHA256: f.sum, StripTop: true}}
623 ctx, cancel := context.WithCancel(context.Background())
624 defer cancel()
625 m.Start(ctx, time.Minute)
626 if !m.Externals()[0].Unavailable {
627 t.Fatal("available while the server is down")
628 }
629 up.Store(true)
630 deadline := time.Now().Add(10 * time.Second)
631 for time.Now().Before(deadline) {
632 time.Sleep(20 * time.Millisecond)
633 if !m.Externals()[0].Unavailable {
634 return
635 }
636 }
637 t.Fatal("never retried")
638}
639
640func TestStartDoesNotWaitLongerThanAsked(t *testing.T) {
641 block := make(chan struct{})
642 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-block }))
643 defer srv.Close()
644 m := &Manager{Dir: filepath.Join(t.TempDir(), "e"), Arch: "x64", Client: srv.Client()}
645 m.Table = []Artifact{{Name: "node24", Version: "2.0.0", Arch: "x64", URL: srv.URL + "/a.tar.gz", SHA256: strings.Repeat("a", 64)}}
646 ctx, cancel := context.WithCancel(context.Background())
647 start := time.Now()
648 m.Start(ctx, 100*time.Millisecond)
649 if d := time.Since(start); d > 5*time.Second {
650 t.Fatalf("waited %v", d)
651 }
652 // The download goes on in the background; stop it before the directory goes.
653 cancel()
654 close(block)
655 for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
656 m.mu.Lock()
657 busy := m.syncing
658 m.mu.Unlock()
659 if !busy {
660 return
661 }
662 }
663 t.Fatal("the background sync did not stop")
664}
665
666// ---- the directories of unavailable tools
667
668func TestStubIsBuiltOnceAndNeverRewrittenInPlace(t *testing.T) {
669 m := &Manager{Dir: filepath.Join(t.TempDir(), "e"), Arch: "x64"}
670 e := m.unavailable("node24", "why", false)
671 node := filepath.Join(e.ext.Dir, "bin", "node")
672 file := filepath.Join(e.ext.Dir, sandbox.ExternalUnavailableFile)
673 ino := func(p string) uint64 {
674 st, err := os.Stat(p)
675 if err != nil {
676 t.Fatal(err)
677 }
678 return st.Sys().(*syscall.Stat_t).Ino
679 }
680 n0, f0 := ino(node), ino(file)
681
682 // Again, from this process and from a fresh one (a restart): same files.
683 m.unavailable("node24", "why", false)
684 (&Manager{Dir: m.Dir, Arch: "x64"}).unavailable("node24", "why", false)
685 if ino(node) != n0 || ino(file) != f0 {
686 t.Fatal("an existing, correct stub was rewritten")
687 }
688
689 // A damaged or missing file is replaced by a rename (a new inode, so a reader
690 // of the old one never sees it change), with the right mode.
691 if err := os.WriteFile(node, nil, 0o755); err != nil {
692 t.Fatal(err)
693 }
694 if err := os.Remove(file); err != nil {
695 t.Fatal(err)
696 }
697 (&Manager{Dir: m.Dir, Arch: "x64"}).unavailable("node24", "why", false)
698 if b, _ := os.ReadFile(node); string(b) != unavailableStub {
699 t.Fatalf("stub not repaired: %q", b)
700 }
701 if b, _ := os.ReadFile(file); string(b) != "why\n" {
702 t.Fatalf("reason not repaired: %q", b)
703 }
704 if st, _ := os.Stat(node); st.Mode().Perm() != 0o755 || ino(node) == n0 {
705 t.Fatalf("repair in place: %v", st.Mode())
706 }
707 runStub(t, e.ext, "why")
708 left, _ := filepath.Glob(filepath.Join(e.ext.Dir, "*", ".tmp-*"))
709 left2, _ := filepath.Glob(filepath.Join(e.ext.Dir, ".tmp-*"))
710 if len(left)+len(left2) != 0 {
711 t.Fatalf("temporary files left: %v %v", left, left2)
712 }
713}
714
715// While Externals() and Sync() run in several goroutines, a reader that walks
716// the directories of the unavailable tools (as the build of a VM's externals
717// disk does) never sees a missing, empty or partial file.
718func TestConcurrentExternalsAndSyncNeverShowAHalfWrittenStub(t *testing.T) {
719 f := newFixture(t)
720 f.srv.Close() // offline: every tool is unavailable
721 ctx := context.Background()
722 stop := make(chan struct{})
723 var wg sync.WaitGroup
724 var bad atomic.Value
725 check := func(dir string) {
726 for _, c := range []struct{ name, want string }{{"bin/node", unavailableStub}} {
727 b, err := os.ReadFile(filepath.Join(dir, c.name))
728 if err != nil {
729 bad.Store(fmt.Sprintf("%s/%s: %v", dir, c.name, err))
730 } else if string(b) != c.want {
731 bad.Store(fmt.Sprintf("%s/%s: %q", dir, c.name, b))
732 }
733 }
734 b, err := os.ReadFile(filepath.Join(dir, sandbox.ExternalUnavailableFile))
735 if err != nil || len(b) == 0 || !strings.HasSuffix(string(b), "\n") {
736 bad.Store(fmt.Sprintf("%s/UNAVAILABLE: %q %v", dir, b, err))
737 }
738 }
739 for i := 0; i < 4; i++ {
740 wg.Add(1)
741 go func() {
742 defer wg.Done()
743 for {
744 select {
745 case <-stop:
746 return
747 default:
748 }
749 for _, e := range f.m.Externals() {
750 check(e.Dir) // what a driver would hand to the disk build
751 }
752 }
753 }()
754 }
755 wg.Add(1)
756 go func() {
757 defer wg.Done()
758 for {
759 select {
760 case <-stop:
761 return
762 default:
763 }
764 dirs, _ := filepath.Glob(filepath.Join(f.dir, unavailableDir, "*"))
765 for _, d := range dirs {
766 if !strings.Contains(filepath.Base(d), ".tmp-") {
767 check(d)
768 }
769 }
770 }
771 }()
772 for i := 0; i < 5; i++ {
773 _ = f.m.Sync(ctx)
774 }
775 close(stop)
776 wg.Wait()
777 if v := bad.Load(); v != nil {
778 t.Fatalf("a reader saw an incomplete stub: %v", v)
779 }
780}
781
782func TestPruneKeepsStubDirectoriesHandedOutInTheLastHour(t *testing.T) {
783 f := newFixture(t)
784 f.srv.Close()
785 _ = f.m.Sync(context.Background()) // offline: stubs for the three tools
786 root := filepath.Join(f.dir, unavailableDir)
787 mk := func(name string, age time.Duration) string {
788 p := filepath.Join(root, name)
789 if err := os.MkdirAll(filepath.Join(p, "bin"), 0o755); err != nil {
790 t.Fatal(err)
791 }
792 old := time.Now().Add(-age)
793 if err := os.Chtimes(p, old, old); err != nil {
794 t.Fatal(err)
795 }
796 return p
797 }
798 fresh := mk("node24-unavailable-aaaaaaaaaaaaaaaa", 5*time.Minute) // handed to a running job
799 stale := mk("node24-unavailable-bbbbbbbbbbbbbbbb", 2*time.Hour)
800
801 // A reason that is not current any more but was handed out again just now
802 // (Externals touches what it returns) survives; one that was not, does not.
803 cur := f.m.Externals()[0]
804 old := time.Now().Add(-3 * time.Hour)
805 if err := os.Chtimes(cur.Dir, old, old); err != nil {
806 t.Fatal(err)
807 }
808 f.m.Externals() // handed out: touched
809 if st, _ := os.Stat(cur.Dir); time.Since(st.ModTime()) > time.Minute {
810 t.Fatalf("Externals did not touch %s", cur.Dir)
811 }
812 // The set changes (the reasons are not the same any more), the touched
813 // directory is no longer current.
814 f.m.mu.Lock()
815 f.m.state = map[string]entry{}
816 f.m.mu.Unlock()
817 f.m.prune(zerowrap.Logger{}, map[string]bool{})
818 for _, p := range []string{fresh, cur.Dir} {
819 if _, err := os.Stat(p); err != nil {
820 t.Errorf("%s was pruned although handed out in the last hour", p)
821 }
822 }
823 if _, err := os.Stat(stale); err == nil {
824 t.Error("a stale stub directory was kept")
825 }
826}
827
828// ---- retries
829
830func TestRetriesRunOnATickerWithoutAnyJob(t *testing.T) {
831 f := newFixture(t)
832 var up atomic.Bool
833 var calls atomic.Int64
834 srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
835 calls.Add(1)
836 if !up.Load() {
837 http.Error(w, "down", http.StatusServiceUnavailable)
838 return
839 }
840 _, _ = w.Write(f.archive)
841 }))
842 defer srv.Close()
843 m := &Manager{Dir: f.dir, Arch: "x64", Client: srv.Client(), RetryEvery: 20 * time.Millisecond}
844 m.Table = []Artifact{{Name: "node24", Version: "2.0.0", Arch: "x64", URL: srv.URL + "/a-linux-x64.tar.gz", SHA256: f.sum, StripTop: true}}
845 ctx, cancel := context.WithCancel(context.Background())
846 m.Start(ctx, time.Minute)
847
848 // Nobody calls Externals(): the ticker alone retries.
849 deadline := time.Now().Add(10 * time.Second)
850 for calls.Load() < 3 && time.Now().Before(deadline) {
851 time.Sleep(10 * time.Millisecond)
852 }
853 if calls.Load() < 3 {
854 t.Fatalf("%d attempts without any job", calls.Load())
855 }
856 up.Store(true)
857 for time.Now().Before(deadline) {
858 m.mu.Lock()
859 ok := !m.state["node24"].ext.Unavailable
860 m.mu.Unlock()
861 if ok {
862 break
863 }
864 time.Sleep(10 * time.Millisecond)
865 }
866 if m.Externals()[0].Unavailable {
867 t.Fatal("never recovered")
868 }
869
870 // Once everything is there the ticker stops downloading, and with the context
871 // it stops altogether.
872 before := calls.Load()
873 time.Sleep(150 * time.Millisecond)
874 if calls.Load() != before {
875 t.Fatal("the ticker keeps requesting what it has")
876 }
877 cancel()
878 m.mu.Lock()
879 m.state["node24"] = entry{ext: sandbox.External{Name: "node24", Unavailable: true}, retry: true}
880 m.mu.Unlock()
881 time.Sleep(150 * time.Millisecond)
882 if calls.Load() != before {
883 t.Fatal("a retry ran after the context ended")
884 }
885}
886
887// A verdict (wrong checksum) is not retried by the ticker.
888func TestNoRetryForAVerdict(t *testing.T) {
889 f := newFixture(t)
890 f.table[0].SHA256 = strings.Repeat("0", 64)
891 m := &Manager{Dir: f.dir, Arch: "x64", Table: f.table[:1], Client: f.srv.Client(), RetryEvery: 10 * time.Millisecond}
892 ctx, cancel := context.WithCancel(context.Background())
893 defer cancel()
894 m.Start(ctx, time.Minute)
895 hits := f.hits.Load()
896 time.Sleep(150 * time.Millisecond)
897 if f.hits.Load() != hits {
898 t.Fatal("a checksum mismatch was retried")
899 }
900}
901
902// ---- the real archives, when they are at hand
903
904// The actions/alpine_nodejs archives have no top directory (./bin/node). Set
905// ERMITE_RUNNER_TEST_ALPINE_DIR, or leave the files in /tmp/alpine-node.
906func TestRealAlpineArchives(t *testing.T) {
907 dir := os.Getenv("ERMITE_RUNNER_TEST_ALPINE_DIR")
908 if dir == "" {
909 dir = "/tmp/alpine-node"
910 }
911 ran := false
912 for _, a := range Pinned {
913 if !strings.HasSuffix(a.Name, "_alpine") {
914 continue
915 }
916 archive := filepath.Join(dir, filepath.Base(a.URL))
917 b, err := os.ReadFile(archive)
918 if err != nil {
919 t.Logf("%s: %v", a.Name, err)
920 continue
921 }
922 ran = true
923 t.Run(a.Name, func(t *testing.T) {
924 sum := sha256.Sum256(b)
925 if err := VerifySum(hex.EncodeToString(sum[:]), a.SHA256); err != nil {
926 t.Fatalf("the pinned checksum is not the file's: %v", err)
927 }
928 dst := t.TempDir()
929 if err := extractTarGz(archive, dst, a.StripTop); err != nil {
930 t.Fatal(err)
931 }
932 if !isReady(dst) {
933 t.Fatal("no executable bin/node")
934 }
935 if st, err := os.Stat(filepath.Join(dst, "LICENSE")); err != nil || st.Mode().Perm() != 0o644 {
936 t.Fatalf("LICENSE: %v %v", st, err)
937 }
938 // Taking the first component off would lose bin/node.
939 wrong := t.TempDir()
940 _ = extractTarGz(archive, wrong, true)
941 if isReady(wrong) {
942 t.Fatal("the alpine archive must not be unpacked with the top directory stripped")
943 }
944 })
945 }
946 if !ran {
947 t.Skipf("no alpine archive in %s", dir)
948 }
949}