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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue