internal/agent/imagecache/imagecache_test.go
Ref: Size: 23.3 KiB History
package imagecache
import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// tinyQcow2 is a real qcow2 image — 256 KiB virtual, 4 KiB clusters — holding
// data in its first cluster, a long run of zeros, and four bytes at the very
// end. Real because the decoder parses headers for a living: a handmade body
// carrying only the magic number would be rejected as unparsable and would
// exercise the error path while claiming to exercise the decode path.
//
// tinyRaw rebuilds what it must decode to, so the assertion is byte-for-byte
// rather than "some file appeared".
func tinyQcow2(t *testing.T) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", "tiny.qcow2"))
require.NoError(t, err)
return b
}
func tinyRaw() []byte {
raw := make([]byte, 256<<10)
copy(raw, []byte{0xeb, 0x63, 0x90, 0x00})
for i := 4096; i < 8192; i++ {
raw[i] = 0x5A
}
copy(raw[len(raw)-4:], []byte("ETRI"))
return raw
}
func serve(t *testing.T, body []byte) (*httptest.Server, string) {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(body)
}))
t.Cleanup(ts.Close)
sum := sha256.Sum256(body)
return ts, hex.EncodeToString(sum[:])
}
func TestEnsureDownloadsVerifiesAndCaches(t *testing.T) {
body := []byte("pretend-qcow2-image")
var downloads atomic.Int64
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
downloads.Add(1)
w.Write(body)
}))
t.Cleanup(ts.Close)
sum := sha256.Sum256(body)
sha := hex.EncodeToString(sum[:])
c := New(t.TempDir())
p1, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sha, nil)
require.NoError(t, err)
assert.FileExists(t, p1)
p2, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sha, nil)
require.NoError(t, err)
assert.Equal(t, p1, p2)
assert.Equal(t, int64(1), downloads.Load(), "second Ensure must hit the cache, not re-fetch")
}
// TestEnsureMaterializesRawByRename pins the cheapest path: a download that is
// already a raw image becomes the cache entry with a rename, paying for neither
// a decode pass nor a second copy of a multi-gigabyte file.
func TestEnsureMaterializesRawByRename(t *testing.T) {
body := bytes.Repeat([]byte{0x00, 0xeb, 0x63, 0x90}, 1024) // no magic number: raw
ts, sum := serve(t, body)
c := New(t.TempDir())
p, err := c.Ensure(context.Background(), ts.URL+"/disk.raw", sum, nil)
require.NoError(t, err)
got, err := os.ReadFile(p)
require.NoError(t, err)
assert.Equal(t, body, got, "the verified download IS the base image")
assert.Equal(t, sum+".raw", filepath.Base(p))
}
// TestEnsureDecodesQcow2Natively is the point of the decoder: qcow2 becomes a
// format every host can read, not one that needs a tool the host may not have.
// The result must be the exact raw image, offsets and trailing bytes included.
func TestEnsureDecodesQcow2Natively(t *testing.T) {
ts, sum := serve(t, tinyQcow2(t))
c := New(t.TempDir())
p, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum, nil)
require.NoError(t, err)
got, err := os.ReadFile(p)
require.NoError(t, err)
assert.True(t, bytes.Equal(tinyRaw(), got), "qcow2 must decode to the exact raw image")
fi, err := os.Stat(p)
require.NoError(t, err)
assert.Equal(t, int64(256<<10), fi.Size(),
"an image ending in zeros must keep its apparent size")
}
// TestEnsureRejectsUnparsableImagePermanently pins the other half: a header the
// decoder cannot parse is a property of the artifact, since its checksum has
// already matched, so retrying it can only fail the same way.
func TestEnsureRejectsUnparsableImagePermanently(t *testing.T) {
ts, sum := serve(t, append([]byte{'Q', 'F', 'I', 0xfb}, bytes.Repeat([]byte{0x11}, 512)...))
dir := t.TempDir()
c := New(dir)
_, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum, nil)
require.Error(t, err)
var perm interface{ Permanent() bool }
require.ErrorAs(t, err, &perm, "a broken container header must be marked permanent")
assert.True(t, perm.Permanent())
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Empty(t, entries, "a rejected image must leave nothing in the cache")
}
func TestEnsureRejectsChecksumMismatch(t *testing.T) {
ts, _ := serve(t, []byte("evil-bytes"))
c := New(t.TempDir())
_, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", "0000000000000000000000000000000000000000000000000000000000000000", nil)
assert.Error(t, err, "tampered image must be rejected before conversion")
}
// --- M4: sha path traversal guard ---
func TestEnsureRejectsInvalidSha(t *testing.T) {
c := New(t.TempDir())
t.Run("path traversal", func(t *testing.T) {
_, err := c.Ensure(context.Background(), "http://unused", "../../etc/passwd", nil)
assert.Error(t, err, "path traversal sha must be rejected")
assert.Contains(t, err.Error(), "invalid sha256")
})
t.Run("uppercase hex", func(t *testing.T) {
_, err := c.Ensure(context.Background(), "http://unused",
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", nil)
assert.Error(t, err, "uppercase sha must be rejected")
})
t.Run("too short", func(t *testing.T) {
_, err := c.Ensure(context.Background(), "http://unused", "abc123", nil)
assert.Error(t, err)
})
}
// --- I1: atomic conversion via temp file ---
func TestEnsureAtomicConvert_LeftoverPartialIsIgnored(t *testing.T) {
// Genuinely qcow2: only the decoding path builds the temp this test is named
// for. A body no container claims is raw, and raw renames the download
// straight onto final without ever creating one.
body := tinyQcow2(t)
ts, sum := serve(t, body)
dir := t.TempDir()
// Pre-seed a leftover partial converting file from a previous crashed run.
partial := filepath.Join(dir, sum+".raw.converting")
require.NoError(t, os.WriteFile(partial, []byte("corrupt partial"), 0o644))
c := New(dir)
p, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum, nil)
require.NoError(t, err)
// The result must contain the decoded image, not the corrupt partial.
got, err := os.ReadFile(p)
require.NoError(t, err)
assert.True(t, bytes.Equal(tinyRaw(), got), "final cache file must hold the decoded image")
}
func TestEnsureNoStrayFilesAfterSuccess(t *testing.T) {
body := []byte("pretend-qcow2-image")
ts, sum := serve(t, body)
dir := t.TempDir()
c := New(dir)
_, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum, nil)
require.NoError(t, err)
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Len(t, entries, 1, "only the final .raw file should remain in cache dir")
assert.Equal(t, sum+".raw", entries[0].Name())
}
// ensureBytes downloads body into c and returns the cached path.
func ensureBytes(t *testing.T, c *Cache, body []byte) string {
t.Helper()
ts, sha := serve(t, body)
p, err := c.Ensure(context.Background(), ts.URL+"/img", sha, nil)
require.NoError(t, err)
return p
}
// backdate pushes a file's mtime into the past to force LRU ordering.
func backdate(t *testing.T, path string, d time.Duration) {
t.Helper()
old := time.Now().Add(-d)
require.NoError(t, os.Chtimes(path, old, old))
}
// TestEnsureEvictsLRUBeyondCap pins eviction: with MaxBytes set, the
// least-recently-used image beyond the cap is removed after a successful
// Ensure; the freshly-ensured image always survives.
func TestEnsureEvictsLRUBeyondCap(t *testing.T) {
dir := t.TempDir()
c := New(dir)
c.MaxBytes = 150
a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100))
backdate(t, a, time.Hour)
b := ensureBytes(t, c, bytes.Repeat([]byte("b"), 100)) // 200 > 150 → evict a
_, errA := os.Stat(a)
assert.True(t, os.IsNotExist(errA), "LRU image beyond the cap must be evicted")
_, errB := os.Stat(b)
assert.NoError(t, errB, "just-ensured image must never be evicted")
}
// TestEnsureCapZeroDisablesEviction pins the default: MaxBytes 0 keeps
// everything (existing behavior).
func TestEnsureCapZeroDisablesEviction(t *testing.T) {
dir := t.TempDir()
c := New(dir)
a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100))
backdate(t, a, time.Hour)
_ = ensureBytes(t, c, bytes.Repeat([]byte("b"), 100))
_, err := os.Stat(a)
assert.NoError(t, err, "no eviction when MaxBytes is 0")
}
// TestCacheHitRefreshesRecency pins the LRU signal: a cache hit touches the
// file so frequently-used images sort as recent.
func TestCacheHitRefreshesRecency(t *testing.T) {
dir := t.TempDir()
c := New(dir)
body := bytes.Repeat([]byte("a"), 50)
a := ensureBytes(t, c, body)
backdate(t, a, time.Hour)
before, _ := os.Stat(a)
_ = ensureBytes(t, c, body) // same sha → cache hit
after, err := os.Stat(a)
require.NoError(t, err)
assert.True(t, after.ModTime().After(before.ModTime()), "hit must refresh mtime")
}
// TestSweepTempsRemovesAbandonedTempsOnly pins the start-up sweep: both temp
// forms Ensure writes before its atomic rename are reclaimed, and a real cached
// image — which evict alone accounts for — is left untouched.
func TestSweepTempsRemovesAbandonedTempsOnly(t *testing.T) {
dir := t.TempDir()
sha := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
download := filepath.Join(dir, "download-1234")
converting := filepath.Join(dir, sha+".raw.converting-5678")
image := filepath.Join(dir, sha+".raw")
for _, p := range []string{download, converting, image} {
require.NoError(t, os.WriteFile(p, []byte("bytes"), 0o644))
}
New(dir).SweepTemps()
_, err := os.Stat(download)
assert.True(t, os.IsNotExist(err), "abandoned download temp must be swept")
_, err = os.Stat(converting)
assert.True(t, os.IsNotExist(err), "abandoned convert temp must be swept")
_, err = os.Stat(image)
assert.NoError(t, err, "a real cached image must survive the sweep")
}
// TestEvictionNeverRemovesEnsuredImageEvenOverCap pins the best-effort cap:
// the just-ensured image survives even when it alone exceeds MaxBytes.
func TestEvictionNeverRemovesEnsuredImageEvenOverCap(t *testing.T) {
dir := t.TempDir()
c := New(dir)
c.MaxBytes = 10
a := ensureBytes(t, c, bytes.Repeat([]byte("a"), 100))
_, err := os.Stat(a)
assert.NoError(t, err, "cap is best-effort; the image in use survives")
}
// TestConcurrentEnsureFetchesOnce pins the singleflight: per-VM reconcile
// workers create VMs concurrently, so a fleet rolling out one image asks for it
// from several workers at once. Those must collapse into ONE download+convert
// rather than N identical multi-GB fetches racing to rename onto one path.
func TestConcurrentEnsureFetchesOnce(t *testing.T) {
body := []byte("base image bytes")
sum := sha256.Sum256(body)
sha := hex.EncodeToString(sum[:])
var downloads atomic.Int32
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
downloads.Add(1)
<-release // hold every request open so the callers genuinely overlap
w.Write(body)
}))
defer srv.Close()
c := New(t.TempDir())
const callers = 4
paths := make(chan string, callers)
errs := make(chan error, callers)
for range callers {
go func() {
p, err := c.Ensure(context.Background(), srv.URL, sha, nil)
if err != nil {
errs <- err
return
}
paths <- p
}()
}
// Let them all arrive at the singleflight, then let the one download finish.
require.Eventually(t, func() bool { return downloads.Load() >= 1 },
2*time.Second, 10*time.Millisecond, "no caller reached the server")
time.Sleep(100 * time.Millisecond) // any un-deduplicated caller would arrive by now
close(release)
want := filepath.Join(c.dir, sha+".raw")
for range callers {
select {
case err := <-errs:
t.Fatalf("Ensure failed: %v", err)
case p := <-paths:
assert.Equal(t, want, p)
case <-time.After(5 * time.Second):
t.Fatal("Ensure never returned")
}
}
assert.Equal(t, int32(1), downloads.Load(),
"concurrent Ensure calls for one image must share a single download")
data, err := os.ReadFile(want)
require.NoError(t, err)
assert.Equal(t, body, data, "the shared result must be the real image")
}
// gzipped returns body compressed, the shape a published .raw.gz has.
func gzipped(t *testing.T, body []byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := gzip.NewWriter(&buf)
_, err := zw.Write(body)
require.NoError(t, err)
require.NoError(t, zw.Close())
return buf.Bytes()
}
// withQcow2Magic prefixes body with the qcow2 magic, so a test image is
// classified the way a real one would be.
func withQcow2Magic(body []byte) []byte {
return append([]byte{'Q', 'F', 'I', 0xfb}, body...)
}
// TestEnsureDecompressesGzippedRaw pins the format that keeps a published raw
// image smaller than the qcow2 it replaces: gzip is undone in-process, byte for
// byte, with no external tool.
func TestEnsureDecompressesGzippedRaw(t *testing.T) {
raw := concat(
bytes.Repeat([]byte{0xeb}, 4096),
make([]byte, 2<<20),
bytes.Repeat([]byte{0x77}, 4096),
)
ts, sum := serve(t, gzipped(t, raw))
dir := t.TempDir()
c := New(dir)
p, err := c.Ensure(context.Background(), ts.URL+"/img.raw.gz", sum, nil)
require.NoError(t, err)
got, err := os.ReadFile(p)
require.NoError(t, err)
assert.True(t, bytes.Equal(raw, got), "decompressed image must be byte-identical")
fi, err := os.Stat(p)
require.NoError(t, err)
assert.Equal(t, int64(len(raw)), fi.Size())
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Len(t, entries, 1, "no temp may survive a successful decompress")
}
// TestEnsureRejectsCorruptGzipPermanently pins the classification: the sha
// already matched, so the bytes are exactly what the publisher published and
// re-downloading them cannot produce a different outcome.
func TestEnsureRejectsCorruptGzipPermanently(t *testing.T) {
ts, sum := serve(t, []byte{0x1f, 0x8b, 'g', 'a', 'r', 'b', 'a', 'g', 'e', '!'})
c := New(t.TempDir())
_, err := c.Ensure(context.Background(), ts.URL+"/img.raw.gz", sum, nil)
require.Error(t, err)
var p interface{ Permanent() bool }
require.ErrorAs(t, err, &p, "a broken gzip header must be marked permanent")
assert.True(t, p.Permanent())
}
// TestEnsureRejectsGzippedQcow2Permanently pins the one trap gzip opens: gzip
// wraps a RAW image, and a .qcow2.gz would otherwise decompress into a cache
// entry named <sha>.raw holding qcow2 bytes — a guest that fails to boot for a
// reason no error names. Permanent because the sha already matched: the bytes
// are what the publisher published, and re-fetching cannot change them.
func TestEnsureRejectsGzippedQcow2Permanently(t *testing.T) {
ts, sum := serve(t, gzipped(t, withQcow2Magic(bytes.Repeat([]byte{0x42}, 4096))))
dir := t.TempDir()
c := New(dir)
_, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2.gz", sum, nil)
require.Error(t, err)
var p interface{ Permanent() bool }
require.ErrorAs(t, err, &p, "a gzipped qcow2 can never decompress to a raw image")
assert.True(t, p.Permanent())
assert.Contains(t, err.Error(), "qcow2", "the operator must learn what the image actually is")
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Empty(t, entries, "a rejected image must leave nothing in the cache")
}
// TestEnsureReportsDownloadProgress is the narration leg: a multi-gigabyte
// image is minutes of silence unless the download says how far it has come, and
// the length the server declared is what turns "how far" into "how far of what".
func TestEnsureReportsDownloadProgress(t *testing.T) {
body := bytes.Repeat([]byte{0x00, 0xeb, 0x63, 0x90}, 4096) // 16 KiB of raw
// Declares its length, the way a server handing out an image file does.
// httptest's default is chunked, which is the other case entirely.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.Write(body)
}))
t.Cleanup(ts.Close)
digest := sha256.Sum256(body)
sum := hex.EncodeToString(digest[:])
c := New(t.TempDir())
c.progressStep = 4 << 10
type step struct{ done, total int64 }
var steps []step
_, err := c.Ensure(context.Background(), ts.URL+"/disk.raw", sum, func(done, total int64) {
steps = append(steps, step{done, total})
})
require.NoError(t, err)
require.NotEmpty(t, steps, "a download must say how far it has come")
for _, s := range steps {
assert.Equal(t, int64(len(body)), s.total, "the declared length rides every report")
assert.LessOrEqual(t, s.done, int64(len(body)))
}
for i := 1; i < len(steps); i++ {
assert.Greater(t, steps[i].done, steps[i-1].done, "progress only moves forward")
}
}
// TestEnsureProgressWithoutDeclaredLength: a server that will not say how long
// the body is (chunked) still gets counted, with a total of -1 saying so. The
// renderer downstream needs that difference to be legible, not guessed at.
func TestEnsureProgressWithoutDeclaredLength(t *testing.T) {
body := bytes.Repeat([]byte{0x00, 0xeb, 0x63, 0x90}, 4096)
ts, sum := serve(t, body) // httptest without a Content-Length: chunked
c := New(t.TempDir())
c.progressStep = 4 << 10
var totals []int64
_, err := c.Ensure(context.Background(), ts.URL+"/disk.raw", sum, func(_, total int64) {
totals = append(totals, total)
})
require.NoError(t, err)
require.NotEmpty(t, totals)
for _, total := range totals {
assert.Negative(t, total, "an undeclared length is reported as unknown, not as zero")
}
}
// TestProgressThrottlesToItsStep pins the throttle itself: the callback feeds a
// published report row, so a download must not call it once per read.
func TestProgressThrottlesToItsStep(t *testing.T) {
var calls int
w := &progressWriter{total: 400, step: 100, report: func(int64, int64) { calls++ }}
for range 40 {
_, err := w.Write(make([]byte, 10))
require.NoError(t, err)
}
assert.Equal(t, 4, calls, "40 writes of 10 bytes past a 100-byte step is 4 reports, not 40")
}
// TestEnsureWithoutProgressStaysQuiet: the callback is optional, and a cache hit
// downloads nothing to report on.
func TestEnsureCacheHitReportsNothing(t *testing.T) {
body := bytes.Repeat([]byte{0x00, 0xeb, 0x63, 0x90}, 1024)
ts, sum := serve(t, body)
c := New(t.TempDir())
c.progressStep = 1
_, err := c.Ensure(context.Background(), ts.URL+"/disk.raw", sum, nil)
require.NoError(t, err)
called := false
_, err = c.Ensure(context.Background(), ts.URL+"/disk.raw", sum, func(int64, int64) { called = true })
require.NoError(t, err)
assert.False(t, called, "a cache hit fetches nothing, so it reports nothing")
}
// qcow2WithBackingFile builds a qcow2 image whose header names backing as its
// backing file. The image is one 64 KiB cluster with an all-zero L1 table, so
// every cluster is unallocated and a decoder that honours the backing file
// reads the whole virtual disk out of it. Version 2 keeps the header to the
// fields that matter here; the eight zero bytes after it are the end-of-
// extensions marker.
func qcow2WithBackingFile(t *testing.T, backing string) []byte {
t.Helper()
const (
clusterBits = 16
clusterSize = 1 << clusterBits
nameOffset = 512
l1TableOff = clusterSize
virtualBytes = clusterSize
)
var h bytes.Buffer
write := func(v any) {
require.NoError(t, binary.Write(&h, binary.BigEndian, v))
}
h.WriteString("QFI\xfb")
write(uint32(2)) // version
write(uint64(nameOffset)) // backing file offset
write(uint32(len(backing))) // backing file size
write(uint32(clusterBits)) // cluster bits
write(uint64(virtualBytes)) // virtual disk size
write(uint32(0)) // crypt method: none
write(uint32(1)) // L1 entries
write(uint64(l1TableOff)) // L1 table offset
write(uint64(0)) // refcount table offset
write(uint32(0)) // refcount table clusters
write(uint32(0)) // snapshot count
write(uint64(0)) // snapshots offset
require.Equal(t, 72, h.Len(), "the v2 header is 72 bytes")
img := make([]byte, l1TableOff+clusterSize)
copy(img, h.Bytes())
copy(img[nameOffset:], backing)
return img
}
// TestEnsureRefusesQcow2WithBackingFile pins the boundary a base image may not
// cross: eitri boots only self-contained images, so an image that declares a
// backing file is refused rather than decoded with host files read in through
// it. Both spellings a qcow2 header allows are covered — an absolute path,
// which needs nothing from the file underneath, and a relative one, which
// resolves against the directory the download landed in.
func TestEnsureRefusesQcow2WithBackingFile(t *testing.T) {
const sentinel = "SENTINEL-host-credential-must-never-reach-a-guest"
for _, tc := range []struct {
name string
backing func(root string) string
}{
{"absolute path", func(root string) string { return filepath.Join(root, "secret.txt") }},
{"relative path escaping the cache dir", func(string) string { return "../secret.txt" }},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "secret.txt"), []byte(sentinel), 0o600))
dir := filepath.Join(root, "cache")
require.NoError(t, os.Mkdir(dir, 0o755))
ts, sum := serve(t, qcow2WithBackingFile(t, tc.backing(root)))
c := New(dir)
_, err := c.Ensure(context.Background(), ts.URL+"/img.qcow2", sum, nil)
require.Error(t, err, "an image declaring a backing file must not be cached")
var perm interface{ Permanent() bool }
require.ErrorAs(t, err, &perm, "the header is the artifact's own, so retrying it cannot help")
assert.True(t, perm.Permanent())
assert.NotContains(t, err.Error(), "secret.txt",
"the refusal names the image, never the path the image asked for")
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Empty(t, entries, "a refused image must leave nothing in the cache")
for _, p := range readAll(t, dir) {
assert.NotContains(t, string(p), sentinel, "no host file may reach a cache entry")
}
})
}
}
// readAll returns the contents of every file under dir, so a test can assert
// that something never made it into any of them.
func readAll(t *testing.T, dir string) [][]byte {
t.Helper()
var out [][]byte
require.NoError(t, filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
b, err := os.ReadFile(p)
if err != nil {
return err
}
out = append(out, b)
return nil
}))
return out
}
// TestEnsureRejectsUnsupportedContainerPermanently pins the containers eitri
// recognises but cannot decode. They are named in the refusal and rejected,
// rather than falling through to raw and reaching a guest as a disk whose first
// sector is a header. Permanent because the sha already matched: these bytes are
// what the publisher published.
func TestEnsureRejectsUnsupportedContainerPermanently(t *testing.T) {
for _, tc := range []struct {
format string
magic string
}{
{"vmdk", "KDMV"},
{"vhdx", "vhdxfile"},
} {
t.Run(tc.format, func(t *testing.T) {
ts, sum := serve(t, append([]byte(tc.magic), bytes.Repeat([]byte{0x00}, 512)...))
dir := t.TempDir()
c := New(dir)
_, err := c.Ensure(context.Background(), ts.URL+"/img."+tc.format, sum, nil)
require.Error(t, err)
var perm interface{ Permanent() bool }
require.ErrorAs(t, err, &perm, "a container eitri cannot decode can only fail the same way twice")
assert.True(t, perm.Permanent())
assert.Contains(t, err.Error(), tc.format, "the operator must learn what the image actually is")
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Empty(t, entries, "a rejected image must leave nothing in the cache")
})
}
}