banger/internal/imagepull/imagepull_test.go
Thales Maciel fdaf7cce0f
imagepull + kernelcat: allow absolute symlink targets
Container (and kernel) layers routinely ship symlinks with absolute
targets — /usr/bin/mawk, /lib/modules/<ver>/build, etc. Those are
interpreted relative to the rootfs at runtime (`/` inside the VM),
not against the host filesystem, so they are rooted inside dest by
construction and need no escape check at write time.

The previous logic resolved absolute Linknames literally (against
the host root), compared to the staging dir, and rejected everything
that didn't happen to live under it. That made `banger image pull
docker.io/library/debian:bookworm` fail on the very first symlink
("etc/alternatives/awk -> /usr/bin/mawk").

Relative targets still get the traversal check — a relative
Linkname with ../s can genuinely escape dest at write time even if
in-VM resolution would be safe — so the defense against malicious
relative chains is intact.

Tests:
 - TestFlattenAcceptsAbsoluteSymlink replaces the old overly-strict
   test, using the exact etc/alternatives/awk -> /usr/bin/mawk case
   that broke debian:bookworm.
 - TestFlattenRejectsRelativeSymlinkEscape confirms relative-with-
   traversal is still rejected with the same "unsafe symlink"
   error.

Same fix applied in internal/kernelcat/fetch.go for consistency;
future kernel bundles with absolute symlinks in the modules tree
would otherwise hit the same wall.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:33:16 -03:00

330 lines
9.2 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"
)
// 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 TestPullCachesLayersAndReturnsImage(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)
}
// Cache should now hold at least one blob.
blobsRoot := filepath.Join(cacheDir, "blobs")
count := 0
_ = filepath.WalkDir(blobsRoot, func(_ string, d os.DirEntry, _ error) error {
if d != nil && !d.IsDir() {
count++
}
return nil
})
if count == 0 {
t.Fatalf("no blobs cached under %s", 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 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)
}
}