Skip to content
quarry Artifact [ae061721f3]
Login

Artifact [ae061721f3]

Artifact ae061721f3fad1e0a62f6a6ddf8d1a0c0411f54b7e7b8965965f124a43cac3b7:


package archive

import (
	"errors"
	"os"
	"path/filepath"
	"sync"
	"sync/atomic"
	"testing"
	"time"
)

func write(b []byte) func(string) error {
	return func(dst string) error { return os.WriteFile(dst, b, 0o644) }
}

func TestGetBuildsOnceAndReuses(t *testing.T) {
	c, err := New(t.TempDir(), 1<<20)
	if err != nil {
		t.Fatal(err)
	}
	var builds atomic.Int32
	fill := func(dst string) error {
		builds.Add(1)
		return os.WriteFile(dst, []byte("payload"), 0o644)
	}
	for range 3 {
		p, err := c.Get("repo/abc.tar", fill)
		if err != nil {
			t.Fatal(err)
		}
		if b, _ := os.ReadFile(p); string(b) != "payload" {
			t.Fatalf("content = %q", b)
		}
	}
	if n := builds.Load(); n != 1 {
		t.Errorf("built %d times, want 1 — the second read should have hit the cache", n)
	}
}

// Ten readers asking for the same popular archive must run one build,
// not ten. Without single-flighting, a cache makes the worst case worse:
// every concurrent reader starts a subprocess AND writes a file.
func TestConcurrentGetsBuildOnce(t *testing.T) {
	c, err := New(t.TempDir(), 1<<20)
	if err != nil {
		t.Fatal(err)
	}
	var builds atomic.Int32
	fill := func(dst string) error {
		builds.Add(1)
		time.Sleep(20 * time.Millisecond) // long enough for the others to arrive
		return os.WriteFile(dst, []byte("x"), 0o644)
	}
	var wg sync.WaitGroup
	for range 10 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if _, err := c.Get("k", fill); err != nil {
				t.Error(err)
			}
		}()
	}
	wg.Wait()
	if n := builds.Load(); n != 1 {
		t.Errorf("built %d times concurrently, want 1", n)
	}
}

// A failed build must leave nothing behind: a truncated file that a
// later reader served as a complete archive is the worst outcome here,
// because it looks like success.
func TestFailedBuildLeavesNoEntry(t *testing.T) {
	dir := t.TempDir()
	c, err := New(dir, 1<<20)
	if err != nil {
		t.Fatal(err)
	}
	boom := errors.New("boom")
	if _, err := c.Get("k", func(dst string) error {
		os.WriteFile(dst, []byte("half"), 0o644) // partial write, then fail
		return boom
	}); !errors.Is(err, boom) {
		t.Fatalf("err = %v, want boom", err)
	}
	entries, _ := filepath.Glob(filepath.Join(dir, "*"))
	if len(entries) != 0 {
		t.Errorf("left %v behind after a failed build", entries)
	}
	// And the next caller gets a real build rather than the debris.
	p, err := c.Get("k", write([]byte("whole")))
	if err != nil {
		t.Fatal(err)
	}
	if b, _ := os.ReadFile(p); string(b) != "whole" {
		t.Errorf("content = %q, want whole", b)
	}
}

// Entries are immutable, so evicting one costs a rebuild and never a
// wrong answer — but the budget must actually be enforced or the disk
// grows without limit.
func TestEvictionHonoursTheBudget(t *testing.T) {
	dir := t.TempDir()
	c, err := New(dir, 250)
	if err != nil {
		t.Fatal(err)
	}
	for _, k := range []string{"a", "b", "c", "d"} {
		if _, err := c.Get(k, write(make([]byte, 100))); err != nil {
			t.Fatal(err)
		}
		// Space the entries in time so LRU order is well-defined.
		nowFunc = func() time.Time { return time.Now().Add(time.Duration(len(k)) * time.Second) }
	}
	nowFunc = time.Now
	var total int64
	entries, _ := os.ReadDir(dir)
	for _, e := range entries {
		fi, err := e.Info()
		if err != nil {
			continue
		}
		total += fi.Size()
	}
	if total > 250 {
		t.Errorf("cache holds %d bytes, over the 250-byte budget", total)
	}
	if total == 0 {
		t.Error("eviction emptied the cache; it should keep what fits")
	}
}

// A key is hashed, so a repository name carrying a slash or dots cannot
// escape the cache directory.
func TestKeyCannotEscapeTheDirectory(t *testing.T) {
	dir := t.TempDir()
	c, err := New(dir, 1<<20)
	if err != nil {
		t.Fatal(err)
	}
	p, err := c.Get("../../etc/passwd", write([]byte("x")))
	if err != nil {
		t.Fatal(err)
	}
	if filepath.Dir(p) != dir {
		t.Errorf("entry landed at %s, outside %s", p, dir)
	}
}