Preserve cleanup after daemon restarts and harden OCI and tar imports against filenames that debugfs cannot encode safely. Mirror tap, loop, and dm teardown identity onto VM.Runtime, teach cleanup and reconcile to fall back to those persisted fields when handles.json is missing or corrupt, and clear the recovery state on stop, error, and delete paths. Reject debugfs-hostile entry names during flattening and in ApplyOwnership itself, then add regression coverage for corrupt handles.json recovery and unsafe import paths. Verified with targeted go tests, make lint-go, make lint-shell, and make build.
495 lines
15 KiB
Go
495 lines
15 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 TestFlattenRejectsDebugFSHostilePath(t *testing.T) {
|
|
img, err := mutate.AppendLayers(empty.Image,
|
|
makeLayer(t, []tarMember{
|
|
{name: `etc/bad"name`, body: []byte("bad")},
|
|
}),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("AppendLayers: %v", err)
|
|
}
|
|
pulled := PulledImage{
|
|
Reference: "test/debugfs-hostile",
|
|
Digest: "sha256:test",
|
|
Platform: "linux/amd64",
|
|
Image: img,
|
|
}
|
|
_, err = Flatten(context.Background(), pulled, t.TempDir())
|
|
if !errors.Is(err, errUnsafeDebugFSPath) {
|
|
t.Fatalf("Flatten hostile path: err=%v, want %v", err, errUnsafeDebugFSPath)
|
|
}
|
|
if !strings.Contains(err.Error(), `etc/bad\"name`) {
|
|
t.Fatalf("Flatten hostile path: err=%v, want offending path", err)
|
|
}
|
|
}
|
|
|
|
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 TestFlattenTarRejectsDebugFSHostilePath(t *testing.T) {
|
|
tarData := buildTar(t, []tarMember{
|
|
{name: "etc/bad\tname", body: []byte("bad")},
|
|
})
|
|
_, err := FlattenTar(context.Background(), bytes.NewReader(tarData), t.TempDir())
|
|
if !errors.Is(err, errUnsafeDebugFSPath) {
|
|
t.Fatalf("FlattenTar hostile path: err=%v, want %v", err, errUnsafeDebugFSPath)
|
|
}
|
|
if !strings.Contains(err.Error(), `etc/bad\tname`) {
|
|
t.Fatalf("FlattenTar hostile path: err=%v, want offending path", 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 TestApplyOwnershipRejectsUnsafeMetadataPath(t *testing.T) {
|
|
meta := Metadata{Entries: map[string]FileMeta{
|
|
"bad\nname": {Uid: 0, Gid: 0, Mode: 0o644, Type: tar.TypeReg},
|
|
}}
|
|
err := ApplyOwnership(context.Background(), system.NewRunner(), filepath.Join(t.TempDir(), "rootfs.ext4"), meta)
|
|
if !errors.Is(err, errUnsafeDebugFSPath) {
|
|
t.Fatalf("ApplyOwnership hostile path: err=%v, want %v", err, errUnsafeDebugFSPath)
|
|
}
|
|
if !strings.Contains(err.Error(), `bad\nname`) {
|
|
t.Fatalf("ApplyOwnership hostile path: err=%v, want offending path", err)
|
|
}
|
|
}
|
|
|
|
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},
|
|
}}
|
|
gotBuf, err := buildOwnershipScript(meta)
|
|
if err != nil {
|
|
t.Fatalf("buildOwnershipScript: %v", err)
|
|
}
|
|
got := gotBuf.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)
|
|
}
|
|
}
|