banger/internal/imagepull/ext4.go
Thales Maciel 78376ba6ec
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>
2026-04-16 17:22:13 -03:00

70 lines
1.9 KiB
Go

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
}