Phase B-1: ownership fixup via debugfs pass

imagepull.Flatten now captures per-file uid/gid/mode/type from the
tar headers as it walks layers, returning a Metadata map alongside
the extracted tree. Whiteouts correctly drop the victim's metadata.
The returned Metadata feeds the new imagepull.ApplyOwnership, which
pipes a batched `set_inode_field` script to `debugfs -w -f -`.

Why: mkfs.ext4 -d copies the runner's on-disk uids verbatim, so
without this pass setuid binaries become setuid-nonroot and sshd
refuses to start on the resulting image. With the pass, a pulled
debian:bookworm has /usr/bin/sudo with uid=0 + setuid bit surviving
intact.

imagepull.BuildExt4 signature unchanged; ownership is applied as a
separate step by the daemon orchestrator between BuildExt4 and
StageBootArtifacts, keeping each helper focused. The seam
(d.pullAndFlatten) now returns (Metadata, error) for test stubs to
feed synthetic metadata.

StdinRunner is a new duck-typed extension next to CommandRunner;
the real system.Runner implements RunStdin, test mocks don't need
to unless they exercise stdin. Prevents every existing mock from
growing a new method.

Tests:
 - TestFlattenCapturesHeaderMetadata: setuid bit + mode survive the
   tar-header walk
 - TestApplyOwnershipRewritesUidGidMode: real debugfs round-trip —
   create ext4 with runner's uid, apply synthetic metadata setting
   uid=0 + setuid mode, verify via `debugfs -R stat` that the
   inode now has uid=0 and mode 04755
 - TestBuildOwnershipScriptDeterministic: sorted, well-formed
   sif script output

Debugfs and mkfs.ext4 tests skip if the binaries aren't on PATH.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-16 18:04:22 -03:00
parent 2e4d4b14da
commit 43982a4ae3
No known key found for this signature in database
GPG key ID: 33112E6833C34679
7 changed files with 334 additions and 32 deletions

View file

@ -19,6 +19,7 @@ import (
"banger/internal/buildinfo"
"banger/internal/config"
"banger/internal/daemon/opstate"
"banger/internal/imagepull"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/rpc"
@ -50,7 +51,7 @@ type Daemon struct {
vmDNS *vmdns.Server
vmCaps []vmCapability
imageBuild func(context.Context, imageBuildSpec) error
pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) error
pullAndFlatten func(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error)
requestHandler func(context.Context, rpc.Request) rpc.Response
guestWaitForSSH func(context.Context, string, string, time.Duration) error
guestDial func(context.Context, string, string) (guestSSHClient, error)

View file

@ -86,7 +86,8 @@ func (d *Daemon) PullImage(ctx context.Context, params api.ImagePullParams) (ima
}
defer os.RemoveAll(rootfsTree)
if err := d.runPullAndFlatten(ctx, ref, d.layout.OCICacheDir, rootfsTree); err != nil {
meta, err := d.runPullAndFlatten(ctx, ref, d.layout.OCICacheDir, rootfsTree)
if err != nil {
return model.Image{}, fmt.Errorf("pull oci image: %w", err)
}
@ -106,6 +107,9 @@ func (d *Daemon) PullImage(ctx context.Context, params api.ImagePullParams) (ima
if err := imagepull.BuildExt4(ctx, d.runner, rootfsTree, rootfsExt4, sizeBytes); err != nil {
return model.Image{}, fmt.Errorf("build rootfs ext4: %w", err)
}
if err := imagepull.ApplyOwnership(ctx, d.runner, rootfsExt4, meta); err != nil {
return model.Image{}, fmt.Errorf("apply ownership: %w", err)
}
stagedKernel, stagedInitrd, stagedModules, err := imagemgr.StageBootArtifacts(ctx, d.runner, stagingDir, kernelPath, initrdPath, modulesDir)
if err != nil {
@ -138,13 +142,13 @@ func (d *Daemon) PullImage(ctx context.Context, params api.ImagePullParams) (ima
}
// runPullAndFlatten is the seam tests substitute. nil → real implementation.
func (d *Daemon) runPullAndFlatten(ctx context.Context, ref, cacheDir, destDir string) error {
func (d *Daemon) runPullAndFlatten(ctx context.Context, ref, cacheDir, destDir string) (imagepull.Metadata, error) {
if d.pullAndFlatten != nil {
return d.pullAndFlatten(ctx, ref, cacheDir, destDir)
}
pulled, err := imagepull.Pull(ctx, ref, cacheDir)
if err != nil {
return err
return imagepull.Metadata{}, err
}
return imagepull.Flatten(ctx, pulled, destDir)
}

View file

@ -10,6 +10,7 @@ import (
"testing"
"banger/internal/api"
"banger/internal/imagepull"
"banger/internal/model"
"banger/internal/paths"
"banger/internal/system"
@ -40,14 +41,19 @@ func writeFakeKernelTriple(t *testing.T) (kernelPath, initrdPath, modulesDir str
// stubPullAndFlatten writes a fixed file tree into destDir, simulating a
// successful OCI pull without the network or tarball machinery.
func stubPullAndFlatten(_ context.Context, _ string, _ string, destDir string) error {
func stubPullAndFlatten(_ context.Context, _ string, _ string, destDir string) (imagepull.Metadata, error) {
if err := os.MkdirAll(filepath.Join(destDir, "etc"), 0o755); err != nil {
return err
return imagepull.Metadata{}, err
}
if err := os.WriteFile(filepath.Join(destDir, "etc", "hello"), []byte("world"), 0o644); err != nil {
return err
return imagepull.Metadata{}, err
}
return os.WriteFile(filepath.Join(destDir, "marker"), []byte("ok"), 0o644)
if err := os.WriteFile(filepath.Join(destDir, "marker"), []byte("ok"), 0o644); err != nil {
return imagepull.Metadata{}, err
}
// Tiny synthetic metadata — daemon-level tests exercise the seam
// plumbing, not the ownership pass itself.
return imagepull.Metadata{Entries: map[string]imagepull.FileMeta{}}, nil
}
func TestPullImageHappyPath(t *testing.T) {
@ -146,8 +152,8 @@ func TestPullImageRequiresKernel(t *testing.T) {
func TestPullImageCleansStagingOnFailure(t *testing.T) {
imagesDir := t.TempDir()
kernel, _, _ := writeFakeKernelTriple(t)
failureSeam := func(_ context.Context, _ string, _ string, _ string) error {
return errors.New("network borked")
failureSeam := func(_ context.Context, _ string, _ string, _ string) (imagepull.Metadata, error) {
return imagepull.Metadata{}, errors.New("network borked")
}
d := &Daemon{