The eager "fetch once to surface network errors" loop in Pull was opening each layer's Compressed() stream and immediately closing it without draining. The go-containerregistry filesystem cache populates lazily via tee-on-read — opening and closing without reading wrote ZERO-BYTE blobs into the cache. Every subsequent pull of the same digest then served those corrupted blobs, producing a 1 GiB ext4 containing nothing but banger's injected files. Symptom caught during B-4 live verification: real debian:bookworm pulls had 43 used inodes (out of 65536) and /usr contained only /usr/local — the debian content was silently missing. Fix: remove the eager-fetch loop entirely. Flatten naturally drains layers when it reads them, and the cache populates correctly on that path. Network errors now surface from Flatten instead of Pull, which is fine — they surface at the same place they always had to. Test TestPullCachesLayersAndReturnsImage → TestPullResolvesImageAnd FlattenPopulatesCache, reworded to assert the new contract: Pull resolves the image; Flatten is what populates the cache with non-empty blobs. Users with a corrupted cache from a pre-fix pull must clear it: rm -rf ~/.cache/banger/oci Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
441 lines
13 KiB
Go
441 lines
13 KiB
Go
package imagepull
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"banger/internal/system"
|
|
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
"github.com/google/go-containerregistry/pkg/registry"
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
"github.com/google/go-containerregistry/pkg/v1/empty"
|
|
"github.com/google/go-containerregistry/pkg/v1/mutate"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"github.com/google/go-containerregistry/pkg/v1/tarball"
|
|
)
|
|
|
|
// ensure log import stays used even when registry-logging is silenced.
|
|
var _ = log.New
|
|
|
|
// tarMember is a single entry to put into a fake layer tarball.
|
|
type tarMember struct {
|
|
name string
|
|
mode int64
|
|
body []byte
|
|
link string // for symlinks / hardlinks
|
|
dir bool
|
|
symlink bool
|
|
hardlink bool
|
|
}
|
|
|
|
func buildTar(t *testing.T, members []tarMember) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
tw := tar.NewWriter(&buf)
|
|
for _, m := range members {
|
|
hdr := &tar.Header{Name: m.name, Mode: m.mode}
|
|
switch {
|
|
case m.dir:
|
|
hdr.Typeflag = tar.TypeDir
|
|
if hdr.Mode == 0 {
|
|
hdr.Mode = 0o755
|
|
}
|
|
case m.symlink:
|
|
hdr.Typeflag = tar.TypeSymlink
|
|
hdr.Linkname = m.link
|
|
case m.hardlink:
|
|
hdr.Typeflag = tar.TypeLink
|
|
hdr.Linkname = m.link
|
|
default:
|
|
hdr.Typeflag = tar.TypeReg
|
|
hdr.Size = int64(len(m.body))
|
|
if hdr.Mode == 0 {
|
|
hdr.Mode = 0o644
|
|
}
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
t.Fatalf("tar header: %v", err)
|
|
}
|
|
if hdr.Typeflag == tar.TypeReg && len(m.body) > 0 {
|
|
if _, err := tw.Write(m.body); err != nil {
|
|
t.Fatalf("tar write: %v", err)
|
|
}
|
|
}
|
|
}
|
|
if err := tw.Close(); err != nil {
|
|
t.Fatalf("tar close: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func startRegistry(t *testing.T) string {
|
|
t.Helper()
|
|
srv := httptest.NewServer(registry.New(registry.Logger(log.New(io.Discard, "", 0))))
|
|
t.Cleanup(srv.Close)
|
|
u, err := url.Parse(srv.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return u.Host
|
|
}
|
|
|
|
func makeLayer(t *testing.T, members []tarMember) v1.Layer {
|
|
t.Helper()
|
|
body := buildTar(t, members)
|
|
layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
|
|
return io.NopCloser(bytes.NewReader(body)), nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("LayerFromOpener: %v", err)
|
|
}
|
|
return layer
|
|
}
|
|
|
|
// pushImage assembles a multi-layer image with linux/amd64 platform and
|
|
// pushes it under repo:tag. Returns the canonical reference.
|
|
func pushImage(t *testing.T, host, repo, tag string, layers ...v1.Layer) string {
|
|
t.Helper()
|
|
img, err := mutate.AppendLayers(empty.Image, layers...)
|
|
if err != nil {
|
|
t.Fatalf("AppendLayers: %v", err)
|
|
}
|
|
cfg, err := img.ConfigFile()
|
|
if err != nil {
|
|
t.Fatalf("ConfigFile: %v", err)
|
|
}
|
|
cfg.Architecture = "amd64"
|
|
cfg.OS = "linux"
|
|
img, err = mutate.ConfigFile(img, cfg)
|
|
if err != nil {
|
|
t.Fatalf("ConfigFile mutate: %v", err)
|
|
}
|
|
ref, err := name.NewTag(host + "/" + repo + ":" + tag)
|
|
if err != nil {
|
|
t.Fatalf("NewTag: %v", err)
|
|
}
|
|
if err := remote.Write(ref, img); err != nil {
|
|
t.Fatalf("remote.Write: %v", err)
|
|
}
|
|
return ref.String()
|
|
}
|
|
|
|
func TestPullResolvesImageAndFlattenPopulatesCache(t *testing.T) {
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "v1",
|
|
makeLayer(t, []tarMember{
|
|
{name: "etc/", dir: true},
|
|
{name: "etc/hello", body: []byte("world")},
|
|
}),
|
|
)
|
|
|
|
cacheDir := t.TempDir()
|
|
pulled, err := Pull(context.Background(), ref, cacheDir)
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
if pulled.Digest == "" {
|
|
t.Fatalf("Digest empty")
|
|
}
|
|
if pulled.Platform != "linux/amd64" {
|
|
t.Fatalf("Platform = %q", pulled.Platform)
|
|
}
|
|
|
|
// Pull itself does NOT populate the cache — it defers to Flatten
|
|
// (which drains the layer streams). This is load-bearing: eagerly
|
|
// opening+closing layer readers in Pull leaves zero-byte blobs that
|
|
// poison subsequent pulls of the same digest.
|
|
dest := t.TempDir()
|
|
if _, err := Flatten(context.Background(), pulled, dest); err != nil {
|
|
t.Fatalf("Flatten: %v", err)
|
|
}
|
|
|
|
// Cache now holds at least one non-empty blob.
|
|
blobsRoot := filepath.Join(cacheDir, "blobs")
|
|
nonEmpty := 0
|
|
_ = filepath.WalkDir(blobsRoot, func(p string, d os.DirEntry, _ error) error {
|
|
if d == nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err == nil && info.Size() > 0 {
|
|
nonEmpty++
|
|
}
|
|
return nil
|
|
})
|
|
if nonEmpty == 0 {
|
|
t.Fatalf("no non-empty blobs cached under %s after Flatten", blobsRoot)
|
|
}
|
|
}
|
|
|
|
func TestFlattenAppliesLayersAndWhiteouts(t *testing.T) {
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "wh",
|
|
makeLayer(t, []tarMember{
|
|
{name: "etc/", dir: true},
|
|
{name: "etc/keep", body: []byte("keep")},
|
|
{name: "etc/old", body: []byte("old")},
|
|
}),
|
|
makeLayer(t, []tarMember{
|
|
{name: "etc/.wh.old"}, // delete etc/old
|
|
{name: "etc/new", body: []byte("new")}, // add etc/new
|
|
{name: "var/", dir: true},
|
|
{name: "var/log/", dir: true},
|
|
{name: "var/log/file", body: []byte("log")},
|
|
}),
|
|
makeLayer(t, []tarMember{
|
|
{name: "var/log/.wh..wh..opq"}, // wipe var/log contents from prior layers
|
|
{name: "var/log/fresh", body: []byte("fresh")},
|
|
}),
|
|
)
|
|
|
|
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
dest := t.TempDir()
|
|
if _, err := Flatten(context.Background(), pulled, dest); err != nil {
|
|
t.Fatalf("Flatten: %v", err)
|
|
}
|
|
|
|
checkFile := func(rel, want string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(filepath.Join(dest, rel))
|
|
if err != nil {
|
|
t.Errorf("read %s: %v", rel, err)
|
|
return
|
|
}
|
|
if string(data) != want {
|
|
t.Errorf("%s = %q, want %q", rel, string(data), want)
|
|
}
|
|
}
|
|
checkFile("etc/keep", "keep")
|
|
checkFile("etc/new", "new")
|
|
checkFile("var/log/fresh", "fresh")
|
|
|
|
if _, err := os.Stat(filepath.Join(dest, "etc/old")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("etc/old should have been whited out: stat err=%v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "var/log/file")); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("var/log/file should have been wiped by opaque marker: stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestFlattenRejectsPathTraversal(t *testing.T) {
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "evil",
|
|
makeLayer(t, []tarMember{
|
|
{name: "../escape", body: []byte("bad")},
|
|
}),
|
|
)
|
|
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
dest := t.TempDir()
|
|
_, err = Flatten(context.Background(), pulled, dest)
|
|
if err == nil || !strings.Contains(err.Error(), "unsafe path") {
|
|
t.Fatalf("Flatten escape: err=%v, want unsafe path", err)
|
|
}
|
|
escape := filepath.Join(filepath.Dir(dest), "escape")
|
|
if _, statErr := os.Stat(escape); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Errorf("escape file should not exist: %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestFlattenAcceptsAbsoluteSymlink(t *testing.T) {
|
|
// Container layers regularly contain absolute symlinks like
|
|
// /usr/bin/mawk — they're interpreted relative to the rootfs at
|
|
// boot time, not against the host filesystem. They must extract
|
|
// cleanly.
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "abs-sym",
|
|
makeLayer(t, []tarMember{
|
|
{name: "etc/alternatives/awk", symlink: true, link: "/usr/bin/mawk"},
|
|
}),
|
|
)
|
|
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
dest := t.TempDir()
|
|
if _, err := Flatten(context.Background(), pulled, dest); err != nil {
|
|
t.Fatalf("Flatten: %v", err)
|
|
}
|
|
link := filepath.Join(dest, "etc/alternatives/awk")
|
|
target, err := os.Readlink(link)
|
|
if err != nil {
|
|
t.Fatalf("readlink: %v", err)
|
|
}
|
|
if target != "/usr/bin/mawk" {
|
|
t.Errorf("link target = %q, want /usr/bin/mawk", target)
|
|
}
|
|
}
|
|
|
|
func TestFlattenRejectsRelativeSymlinkEscape(t *testing.T) {
|
|
// Relative symlinks with .. must still be rejected: the resolved
|
|
// path can escape dest at the host level even if the in-VM
|
|
// resolution would be safe.
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "rel-escape",
|
|
makeLayer(t, []tarMember{
|
|
{name: "etc/evil", symlink: true, link: "../../../../etc/passwd"},
|
|
}),
|
|
)
|
|
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
_, err = Flatten(context.Background(), pulled, t.TempDir())
|
|
if err == nil || !strings.Contains(err.Error(), "unsafe symlink") {
|
|
t.Fatalf("Flatten relative escape: err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildExt4ProducesValidImage(t *testing.T) {
|
|
if _, err := exec.LookPath("mkfs.ext4"); err != nil {
|
|
t.Skip("mkfs.ext4 not available; skipping")
|
|
}
|
|
src := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(src, "etc"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(src, "etc", "hello"), []byte("hi"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := filepath.Join(t.TempDir(), "rootfs.ext4")
|
|
if err := BuildExt4(context.Background(), system.NewRunner(), src, out, MinExt4Size); err != nil {
|
|
t.Fatalf("BuildExt4: %v", err)
|
|
}
|
|
info, err := os.Stat(out)
|
|
if err != nil {
|
|
t.Fatalf("stat output: %v", err)
|
|
}
|
|
if info.Size() != MinExt4Size {
|
|
t.Errorf("ext4 size = %d, want %d", info.Size(), MinExt4Size)
|
|
}
|
|
// Quick sanity via file(1) — the ext4 superblock should be detectable.
|
|
if _, err := exec.LookPath("file"); err == nil {
|
|
out, _ := exec.Command("file", "-b", out).Output()
|
|
if !bytes.Contains(out, []byte("ext")) {
|
|
t.Errorf("file(1) does not see an ext filesystem: %s", out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFlattenCapturesHeaderMetadata(t *testing.T) {
|
|
host := startRegistry(t)
|
|
ref := pushImage(t, host, "banger/test", "meta",
|
|
makeLayer(t, []tarMember{
|
|
{name: "usr/bin/sudo", mode: 0o4755, body: []byte("setuid-bin")},
|
|
{name: "etc/", dir: true, mode: 0o755},
|
|
{name: "etc/link", symlink: true, link: "/usr/bin/sudo"},
|
|
}),
|
|
)
|
|
|
|
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Pull: %v", err)
|
|
}
|
|
meta, err := Flatten(context.Background(), pulled, t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("Flatten: %v", err)
|
|
}
|
|
|
|
sudo, ok := meta.Entries["usr/bin/sudo"]
|
|
if !ok {
|
|
t.Fatalf("missing usr/bin/sudo entry: %+v", meta.Entries)
|
|
}
|
|
if sudo.Mode&0o4000 == 0 {
|
|
t.Errorf("setuid bit lost: mode=0%o", sudo.Mode)
|
|
}
|
|
if sudo.Mode&0o777 != 0o755 {
|
|
t.Errorf("perm bits = 0%o, want 0o755", sudo.Mode&0o777)
|
|
}
|
|
|
|
if _, ok := meta.Entries["etc"]; !ok {
|
|
t.Errorf("missing etc dir entry")
|
|
}
|
|
if _, ok := meta.Entries["etc/link"]; !ok {
|
|
t.Errorf("missing symlink entry")
|
|
}
|
|
}
|
|
|
|
func TestApplyOwnershipRewritesUidGidMode(t *testing.T) {
|
|
if _, err := exec.LookPath("mkfs.ext4"); err != nil {
|
|
t.Skip("mkfs.ext4 not available; skipping")
|
|
}
|
|
if _, err := exec.LookPath("debugfs"); err != nil {
|
|
t.Skip("debugfs not available; skipping")
|
|
}
|
|
|
|
// Stage a tiny source tree and build an ext4 with mkfs.ext4 -d.
|
|
src := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(src, "setuid-bin"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := filepath.Join(t.TempDir(), "rootfs.ext4")
|
|
if err := BuildExt4(context.Background(), system.NewRunner(), src, out, MinExt4Size); err != nil {
|
|
t.Fatalf("BuildExt4: %v", err)
|
|
}
|
|
|
|
// Apply synthetic metadata: set uid=0 gid=0 mode=0o4755 on setuid-bin.
|
|
meta := Metadata{Entries: map[string]FileMeta{
|
|
"setuid-bin": {Uid: 0, Gid: 0, Mode: 0o4755, Type: tar.TypeReg},
|
|
}}
|
|
if err := ApplyOwnership(context.Background(), system.NewRunner(), out, meta); err != nil {
|
|
t.Fatalf("ApplyOwnership: %v", err)
|
|
}
|
|
|
|
// Read back the inode via debugfs.
|
|
statOut, err := exec.Command("debugfs", "-R", "stat /setuid-bin", out).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("debugfs stat: %v: %s", err, statOut)
|
|
}
|
|
s := string(statOut)
|
|
if !bytes.Contains([]byte(s), []byte("User: 0")) && !bytes.Contains([]byte(s), []byte("User: 0")) {
|
|
t.Errorf("uid not 0 after fixup. output:\n%s", s)
|
|
}
|
|
if !bytes.Contains([]byte(s), []byte("Mode: 04755")) && !bytes.Contains([]byte(s), []byte("Mode: 4755")) {
|
|
t.Errorf("setuid mode not applied. output:\n%s", s)
|
|
}
|
|
}
|
|
|
|
func TestBuildOwnershipScriptDeterministic(t *testing.T) {
|
|
meta := Metadata{Entries: map[string]FileMeta{
|
|
"b": {Uid: 0, Gid: 0, Mode: 0o755, Type: tar.TypeReg},
|
|
"a": {Uid: 0, Gid: 0, Mode: 0o755, Type: tar.TypeReg},
|
|
"a/x": {Uid: 0, Gid: 0, Mode: 0o644, Type: tar.TypeReg},
|
|
}}
|
|
got := buildOwnershipScript(meta).String()
|
|
// sorted: a, a/x, b
|
|
want := "set_inode_field /a uid 0\nset_inode_field /a gid 0\nset_inode_field /a mode 0100755\n" +
|
|
"set_inode_field /a/x uid 0\nset_inode_field /a/x gid 0\nset_inode_field /a/x mode 0100644\n" +
|
|
"set_inode_field /b uid 0\nset_inode_field /b gid 0\nset_inode_field /b mode 0100755\n"
|
|
if got != want {
|
|
t.Errorf("script mismatch\ngot:\n%s\nwant:\n%s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildExt4RejectsTinySize(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "rootfs.ext4")
|
|
err := BuildExt4(context.Background(), system.NewRunner(), src, out, 1024)
|
|
if err == nil || !strings.Contains(err.Error(), "below minimum") {
|
|
t.Fatalf("BuildExt4 tiny: err=%v", err)
|
|
}
|
|
if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
|
|
t.Errorf("output file should not exist on rejection: %v", statErr)
|
|
}
|
|
}
|