Phase 1: imagepull package — pull, flatten, ext4
New internal/imagepull/ subpackage. Three concerns, each independently testable: Pull (imagepull.go): - github.com/google/go-containerregistry's remote.Image with the linux/amd64 platform pinned. Anonymous pulls only for v1. - Layer blobs cached on disk via cache.NewFilesystemCache under <cacheDir>/blobs/sha256/<hex> — OCI-standard layout so skopeo/crane could co-exist later. - Eagerly touches every layer once so network errors surface at Pull time, not deep in Flatten. Flatten (flatten.go): - Replays layers oldest-first into destDir. - Whiteout-aware: .wh.<name> deletes the named entry, .wh..wh..opq wipes the parent directory's contents from prior layers. - Path-traversal hardening mirrored from kernelcat extractTar: reject .., absolute paths, and symlinks/hardlinks whose resolved target escapes destDir. - Handles tar.TypeReg, TypeDir, TypeSymlink, TypeLink. Skips device/fifo nodes silently (need privilege; udev/devtmpfs handles them in the guest). BuildExt4 (ext4.go): - Truncates outFile to sizeBytes, then runs `mkfs.ext4 -F -d <srcDir> -E root_owner=0:0`. No mount, no sudo, no loopback. - 64 MiB floor; callers handle real sizing with content-aware headroom. - File ownership in the resulting ext4 reflects srcDir's on-disk ownership — runner's uid/gid since extraction was unprivileged. Documented in package doc as a Phase A v1 limitation; Phase B will add a debugfs- or tar2ext4-based ownership fixup. paths.Layout gains OCICacheDir at $XDG_CACHE_HOME/banger/oci/, ensured at startup alongside the other dirs. Tests use go-containerregistry's in-process registry to push and pull synthetic multi-layer images. Cover: layer caching round-trip, whiteout + opaque-marker handling, path-traversal rejection, unsafe symlink rejection, real mkfs.ext4 round-trip (skipped if mkfs.ext4 absent), and tiny-size rejection. go-containerregistry v0.21.5 added as a direct dep, plus its transitive closure (containerd/stargz, opencontainers/go-digest, docker/cli config helpers, etc). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
da4a6bf45b
commit
78376ba6ec
7 changed files with 733 additions and 33 deletions
70
internal/imagepull/ext4.go
Normal file
70
internal/imagepull/ext4.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package imagepull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"banger/internal/system"
|
||||
)
|
||||
|
||||
// MinExt4Size is the smallest ext4 image we'll create. mkfs.ext4 needs a
|
||||
// few megabytes for its bookkeeping; for a real rootfs the staging tree
|
||||
// will dominate anyway.
|
||||
const MinExt4Size int64 = 1 << 20 * 64 // 64 MiB
|
||||
|
||||
// BuildExt4 creates outFile as a sparse ext4 image of sizeBytes and
|
||||
// populates it from srcDir using `mkfs.ext4 -F -d`. No mount, no sudo.
|
||||
//
|
||||
// sizeBytes must be at least MinExt4Size. Callers are expected to size
|
||||
// the file with headroom over the staged tree (the daemon orchestrator
|
||||
// does this; this function only enforces a sanity floor).
|
||||
//
|
||||
// The resulting image's file ownership reflects srcDir's on-disk
|
||||
// ownership — see the package doc for the implications.
|
||||
func BuildExt4(ctx context.Context, runner system.CommandRunner, srcDir, outFile string, sizeBytes int64) error {
|
||||
if sizeBytes < MinExt4Size {
|
||||
return fmt.Errorf("ext4 size %d below minimum %d", sizeBytes, MinExt4Size)
|
||||
}
|
||||
info, err := os.Stat(srcDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat source: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", srcDir)
|
||||
}
|
||||
|
||||
if err := os.Remove(outFile); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Truncate(sizeBytes); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(outFile)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(outFile)
|
||||
return err
|
||||
}
|
||||
|
||||
out, runErr := runner.Run(ctx, "mkfs.ext4",
|
||||
"-F",
|
||||
"-q",
|
||||
"-d", srcDir,
|
||||
"-L", "banger-rootfs",
|
||||
"-E", "root_owner=0:0",
|
||||
outFile,
|
||||
strconv.FormatInt(sizeBytes/4096, 10), // size in 4 KiB blocks
|
||||
)
|
||||
if runErr != nil {
|
||||
_ = os.Remove(outFile)
|
||||
return fmt.Errorf("mkfs.ext4 -d: %w: %s", runErr, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
201
internal/imagepull/flatten.go
Normal file
201
internal/imagepull/flatten.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
package imagepull
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
whiteoutPrefix = ".wh."
|
||||
// whiteoutOpaque marks the parent directory as opaque: every entry
|
||||
// from previous layers should be removed, but entries from the
|
||||
// current layer (siblings of this marker) are preserved.
|
||||
whiteoutOpaque = ".wh..wh..opq"
|
||||
)
|
||||
|
||||
// Flatten replays the image's layers in oldest-first order into destDir.
|
||||
// destDir must exist and ideally be empty. Path-traversal members and
|
||||
// symlink targets that escape destDir are rejected.
|
||||
//
|
||||
// File ownership in destDir reflects the running user, not the tar
|
||||
// header's uid/gid (Phase A v1 limitation; see package docs).
|
||||
func Flatten(ctx context.Context, img PulledImage, destDir string) error {
|
||||
absDest, err := filepath.Abs(destDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
layers, err := img.Image.Layers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read layers: %w", err)
|
||||
}
|
||||
for i, layer := range layers {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyLayer(layer, absDest); err != nil {
|
||||
return fmt.Errorf("apply layer %d/%d: %w", i+1, len(layers), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyLayer(layer interface {
|
||||
Uncompressed() (io.ReadCloser, error)
|
||||
}, dest string) error {
|
||||
rc, err := layer.Uncompressed()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
tr := tar.NewReader(rc)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read tar entry: %w", err)
|
||||
}
|
||||
if err := applyEntry(tr, hdr, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyEntry(tr *tar.Reader, hdr *tar.Header, dest string) error {
|
||||
rel := filepath.Clean(hdr.Name)
|
||||
if rel == "." || rel == string(filepath.Separator) {
|
||||
return nil
|
||||
}
|
||||
if filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return fmt.Errorf("unsafe path in layer: %q", hdr.Name)
|
||||
}
|
||||
|
||||
base := filepath.Base(rel)
|
||||
parent := filepath.Dir(rel)
|
||||
|
||||
// Whiteouts come in two flavors: opaque-dir markers and per-file
|
||||
// deletes. Both are resolved relative to the parent directory.
|
||||
if base == whiteoutOpaque {
|
||||
parentAbs, err := safeJoin(dest, parent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return clearDirContents(parentAbs)
|
||||
}
|
||||
if strings.HasPrefix(base, whiteoutPrefix) {
|
||||
target := strings.TrimPrefix(base, whiteoutPrefix)
|
||||
victim, err := safeJoin(dest, filepath.Join(parent, target))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(victim); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("apply whiteout %s: %w", hdr.Name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
abs, err := safeJoin(dest, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
return os.MkdirAll(abs, 0o755)
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Replace any prior file/dir in this slot — later layers
|
||||
// shadow earlier ones.
|
||||
if err := os.RemoveAll(abs); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(abs, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)|0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
case tar.TypeSymlink:
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve the link target relative to the link's parent and
|
||||
// require that it stays inside dest. Absolute targets that
|
||||
// resolve outside dest are also rejected.
|
||||
resolved := hdr.Linkname
|
||||
if !filepath.IsAbs(resolved) {
|
||||
resolved = filepath.Join(filepath.Dir(abs), resolved)
|
||||
}
|
||||
resolved = filepath.Clean(resolved)
|
||||
if resolved != dest && !strings.HasPrefix(resolved, dest+string(filepath.Separator)) {
|
||||
return fmt.Errorf("unsafe symlink in layer: %q -> %q", hdr.Name, hdr.Linkname)
|
||||
}
|
||||
if err := os.RemoveAll(abs); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(hdr.Linkname, abs)
|
||||
case tar.TypeLink:
|
||||
// Hardlink: target must already exist inside dest from this or
|
||||
// a previous layer, and must not escape.
|
||||
linkTarget, err := safeJoin(dest, filepath.Clean(hdr.Linkname))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Lstat(linkTarget); err != nil {
|
||||
return fmt.Errorf("hardlink target %q missing: %w", hdr.Linkname, err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(abs); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return os.Link(linkTarget, abs)
|
||||
default:
|
||||
// TypeChar / TypeBlock / TypeFifo / TypeXGlobalHeader / etc.
|
||||
// Container layers occasionally include /dev nodes — they need
|
||||
// privilege we don't have. Skip silently; udev/devtmpfs in the
|
||||
// guest will create them at boot.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// safeJoin returns dest+rel after verifying the result lies under dest.
|
||||
func safeJoin(dest, rel string) (string, error) {
|
||||
joined := filepath.Join(dest, rel)
|
||||
if joined != dest && !strings.HasPrefix(joined, dest+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("unsafe path: %q escapes %q", rel, dest)
|
||||
}
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
// clearDirContents removes every entry under dir but leaves dir itself.
|
||||
// Used for opaque-whiteout markers.
|
||||
func clearDirContents(dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
102
internal/imagepull/imagepull.go
Normal file
102
internal/imagepull/imagepull.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// Package imagepull pulls OCI container images from registries and lays
|
||||
// them down as banger-ready ext4 rootfs files. The package is a primitive:
|
||||
// it produces an ext4 file plus per-file ownership metadata. Higher layers
|
||||
// (the daemon's PullImage orchestrator) decide where the file lands and
|
||||
// how it gets registered.
|
||||
//
|
||||
// Three concerns:
|
||||
// - Pull resolves an OCI reference, selects the linux/amd64 platform,
|
||||
// and returns a v1.Image whose layer blobs are cached on disk so
|
||||
// re-pulls are cheap.
|
||||
// - Flatten replays the layers in order into a staging directory,
|
||||
// applies whiteouts, and rejects unsafe paths/symlinks.
|
||||
// - BuildExt4 turns that staging directory into an ext4 file via
|
||||
// `mkfs.ext4 -d` (no mount, no sudo).
|
||||
//
|
||||
// Limitations (Phase A v1):
|
||||
// - Anonymous registry pulls only. Auth is deferred.
|
||||
// - Hardcoded linux/amd64. Other platforms reject at Pull time.
|
||||
// - File ownership in the resulting ext4 is the runner's uid/gid;
|
||||
// setuid binaries and root-owned config files lose their original
|
||||
// ownership. Phase B will add a debugfs- or tar2ext4-based fixup
|
||||
// pass; until then the produced image is suitable as input to
|
||||
// `image build` but not directly bootable.
|
||||
package imagepull
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/google/go-containerregistry/pkg/v1/cache"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
)
|
||||
|
||||
// Platform is the only platform Phase A produces. Adding arm64 later is a
|
||||
// matter of letting callers override this.
|
||||
var Platform = v1.Platform{OS: "linux", Architecture: "amd64"}
|
||||
|
||||
// PulledImage is what Pull returns: the resolved OCI image plus enough
|
||||
// reference metadata to identify it later (digest for cache keys,
|
||||
// canonical name for logs).
|
||||
type PulledImage struct {
|
||||
Reference string // user-supplied reference, parsed and re-stringified
|
||||
Digest string // image manifest digest (sha256:...)
|
||||
Platform string // "linux/amd64"
|
||||
Image v1.Image // go-containerregistry handle; layers, manifest, etc.
|
||||
}
|
||||
|
||||
// Pull resolves ref against the public registry, selects the linux/amd64
|
||||
// platform from any manifest list, and ensures the layer blobs are cached
|
||||
// on disk under cacheDir/blobs/sha256/<hex>. Subsequent Pulls of the same
|
||||
// digest are local-only.
|
||||
func Pull(ctx context.Context, ref, cacheDir string) (PulledImage, error) {
|
||||
parsed, err := name.ParseReference(ref)
|
||||
if err != nil {
|
||||
return PulledImage{}, fmt.Errorf("parse oci ref %q: %w", ref, err)
|
||||
}
|
||||
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
|
||||
return PulledImage{}, err
|
||||
}
|
||||
|
||||
img, err := remote.Image(parsed,
|
||||
remote.WithContext(ctx),
|
||||
remote.WithPlatform(Platform),
|
||||
)
|
||||
if err != nil {
|
||||
return PulledImage{}, fmt.Errorf("fetch %q: %w", ref, err)
|
||||
}
|
||||
|
||||
cached := cache.Image(img, cache.NewFilesystemCache(filepath.Join(cacheDir, "blobs")))
|
||||
|
||||
digest, err := cached.Digest()
|
||||
if err != nil {
|
||||
return PulledImage{}, fmt.Errorf("resolve digest for %q: %w", ref, err)
|
||||
}
|
||||
|
||||
// Touch the layers once so they are guaranteed present in the cache
|
||||
// before Flatten runs; surfaces network errors here, not deep inside
|
||||
// Flatten's hot loop.
|
||||
layers, err := cached.Layers()
|
||||
if err != nil {
|
||||
return PulledImage{}, fmt.Errorf("read layers for %q: %w", ref, err)
|
||||
}
|
||||
for i, layer := range layers {
|
||||
rc, err := layer.Compressed()
|
||||
if err != nil {
|
||||
return PulledImage{}, fmt.Errorf("fetch layer %d for %q: %w", i, ref, err)
|
||||
}
|
||||
_ = rc.Close()
|
||||
}
|
||||
|
||||
return PulledImage{
|
||||
Reference: parsed.String(),
|
||||
Digest: digest.String(),
|
||||
Platform: Platform.OS + "/" + Platform.Architecture,
|
||||
Image: cached,
|
||||
}, nil
|
||||
}
|
||||
298
internal/imagepull/imagepull_test.go
Normal file
298
internal/imagepull/imagepull_test.go
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
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 TestFlattenRejectsUnsafeSymlink(t *testing.T) {
|
||||
host := startRegistry(t)
|
||||
ref := pushImage(t, host, "banger/test", "evil-sym",
|
||||
makeLayer(t, []tarMember{
|
||||
{name: "evil", symlink: true, link: "/etc/passwd"}, // absolute target outside dest
|
||||
}),
|
||||
)
|
||||
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 unsafe symlink: 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ type Layout struct {
|
|||
VMsDir string
|
||||
ImagesDir string
|
||||
KernelsDir string
|
||||
OCICacheDir string
|
||||
}
|
||||
|
||||
func Resolve() (Layout, error) {
|
||||
|
|
@ -54,11 +55,12 @@ func Resolve() (Layout, error) {
|
|||
layout.VMsDir = filepath.Join(layout.StateDir, "vms")
|
||||
layout.ImagesDir = filepath.Join(layout.StateDir, "images")
|
||||
layout.KernelsDir = filepath.Join(layout.StateDir, "kernels")
|
||||
layout.OCICacheDir = filepath.Join(layout.CacheDir, "oci")
|
||||
return layout, nil
|
||||
}
|
||||
|
||||
func Ensure(layout Layout) error {
|
||||
for _, dir := range []string{layout.ConfigDir, layout.StateDir, layout.CacheDir, layout.RuntimeDir, layout.VMsDir, layout.ImagesDir, layout.KernelsDir} {
|
||||
for _, dir := range []string{layout.ConfigDir, layout.StateDir, layout.CacheDir, layout.RuntimeDir, layout.VMsDir, layout.ImagesDir, layout.KernelsDir, layout.OCICacheDir} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue