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)
}
}