banger/internal/paths/paths.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

127 lines
3.5 KiB
Go

package paths
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
type Layout struct {
ConfigHome string
StateHome string
CacheHome string
RuntimeHome string
ConfigDir string
StateDir string
CacheDir string
RuntimeDir string
SocketPath string
DBPath string
DaemonLog string
VMsDir string
ImagesDir string
KernelsDir string
OCICacheDir string
}
func Resolve() (Layout, error) {
home, err := os.UserHomeDir()
if err != nil {
return Layout{}, err
}
configHome := getenvDefault("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
stateHome := getenvDefault("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
cacheHome := getenvDefault("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
runtimeHome := os.Getenv("XDG_RUNTIME_DIR")
if runtimeHome == "" {
runtimeHome = filepath.Join(os.TempDir(), fmt.Sprintf("banger-runtime-%d", os.Getuid()))
}
layout := Layout{
ConfigHome: configHome,
StateHome: stateHome,
CacheHome: cacheHome,
RuntimeHome: runtimeHome,
ConfigDir: filepath.Join(configHome, "banger"),
StateDir: filepath.Join(stateHome, "banger"),
CacheDir: filepath.Join(cacheHome, "banger"),
RuntimeDir: filepath.Join(runtimeHome, "banger"),
}
layout.SocketPath = filepath.Join(layout.RuntimeDir, "bangerd.sock")
layout.DBPath = filepath.Join(layout.StateDir, "state.db")
layout.DaemonLog = filepath.Join(layout.StateDir, "bangerd.log")
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, layout.OCICacheDir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
return nil
}
var executablePath = os.Executable
func BangerdPath() (string, error) {
if env := os.Getenv("BANGER_DAEMON_BIN"); env != "" {
return env, nil
}
exe, err := executablePath()
if err != nil {
return "", err
}
dir := filepath.Dir(exe)
for _, candidate := range []string{
filepath.Join(dir, "bangerd"),
filepath.Join(dir, "bangerd.exe"),
} {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", errors.New("bangerd binary not found next to banger; run `make build`")
}
func CompanionBinaryPath(name string) (string, error) {
envNames := []string{
"BANGER_" + strings.ToUpper(strings.NewReplacer("-", "_", ".", "_").Replace(name)) + "_BIN",
}
if trimmed, ok := strings.CutPrefix(name, "banger-"); ok {
envNames = append(envNames, "BANGER_"+strings.ToUpper(strings.NewReplacer("-", "_", ".", "_").Replace(trimmed))+"_BIN")
}
for _, envName := range envNames {
if env := strings.TrimSpace(os.Getenv(envName)); env != "" {
return env, nil
}
}
exe, err := executablePath()
if err != nil {
return "", err
}
exeDir := filepath.Dir(exe)
for _, candidate := range []string{
filepath.Join(exeDir, name),
filepath.Join(exeDir, "..", "lib", "banger", name),
filepath.Join(exeDir, "..", "libexec", "banger", name),
} {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", fmt.Errorf("%s companion binary not found; run `make build` or reinstall banger", name)
}
func getenvDefault(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}