a73x

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