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,35 +19,60 @@ const (
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.
// FileMeta captures the per-file metadata we need to reconstruct after
// mkfs.ext4 has placed the bytes on disk. Uid/Gid/Mode come straight
// from the tar header; mode carries the full set of permission bits
// including setuid/setgid/sticky.
type FileMeta struct {
Uid int
Gid int
Mode int64 // tar header mode (perm + setuid/sgid/sticky)
Type byte // tar typeflag (TypeReg, TypeDir, TypeSymlink, …)
}
// Metadata records ownership/mode for every path that made it into
// destDir. Keys are relative to destDir, never starting with "/". Order
// is the final-layer order — later layers shadow earlier ones.
type Metadata struct {
Entries map[string]FileMeta
}
func newMetadata() Metadata {
return Metadata{Entries: make(map[string]FileMeta)}
}
// Flatten replays the image's layers in oldest-first order into destDir
// and returns a Metadata record of each surviving file's tar-header
// ownership/mode. 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 {
// The returned Metadata feeds ApplyOwnership: Go's unprivileged
// extraction can't set real uids/gids on disk, but a debugfs pass over
// the final ext4 can.
func Flatten(ctx context.Context, img PulledImage, destDir string) (Metadata, error) {
meta := newMetadata()
absDest, err := filepath.Abs(destDir)
if err != nil {
return err
return meta, err
}
layers, err := img.Image.Layers()
if err != nil {
return fmt.Errorf("read layers: %w", err)
return meta, fmt.Errorf("read layers: %w", err)
}
for i, layer := range layers {
if err := ctx.Err(); err != nil {
return err
return meta, err
}
if err := applyLayer(layer, absDest); err != nil {
return fmt.Errorf("apply layer %d/%d: %w", i+1, len(layers), err)
if err := applyLayer(layer, absDest, &meta); err != nil {
return meta, fmt.Errorf("apply layer %d/%d: %w", i+1, len(layers), err)
}
}
return nil
return meta, nil
}
func applyLayer(layer interface {
Uncompressed() (io.ReadCloser, error)
}, dest string) error {
}, dest string, meta *Metadata) error {
rc, err := layer.Uncompressed()
if err != nil {
return err
@ -63,13 +88,13 @@ func applyLayer(layer interface {
if err != nil {
return fmt.Errorf("read tar entry: %w", err)
}
if err := applyEntry(tr, hdr, dest); err != nil {
if err := applyEntry(tr, hdr, dest, meta); err != nil {
return err
}
}
}
func applyEntry(tr *tar.Reader, hdr *tar.Header, dest string) error {
func applyEntry(tr *tar.Reader, hdr *tar.Header, dest string, meta *Metadata) error {
rel := filepath.Clean(hdr.Name)
if rel == "." || rel == string(filepath.Separator) {
return nil
@ -83,11 +108,19 @@ func applyEntry(tr *tar.Reader, hdr *tar.Header, dest string) error {
// Whiteouts come in two flavors: opaque-dir markers and per-file
// deletes. Both are resolved relative to the parent directory.
// Whiteouts erase metadata for the victim path(s).
if base == whiteoutOpaque {
parentAbs, err := safeJoin(dest, parent)
if err != nil {
return err
}
// Drop metadata entries whose path is under parent.
prefix := parent + "/"
for k := range meta.Entries {
if parent == "." || parent == "" || strings.HasPrefix(k, prefix) {
delete(meta.Entries, k)
}
}
return clearDirContents(parentAbs)
}
if strings.HasPrefix(base, whiteoutPrefix) {
@ -96,6 +129,14 @@ func applyEntry(tr *tar.Reader, hdr *tar.Header, dest string) error {
if err != nil {
return err
}
victimKey := filepath.Clean(filepath.Join(parent, target))
delete(meta.Entries, victimKey)
victimPrefix := victimKey + "/"
for k := range meta.Entries {
if strings.HasPrefix(k, victimPrefix) {
delete(meta.Entries, k)
}
}
if err := os.RemoveAll(victim); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("apply whiteout %s: %w", hdr.Name, err)
}
@ -109,7 +150,11 @@ func applyEntry(tr *tar.Reader, hdr *tar.Header, dest string) error {
switch hdr.Typeflag {
case tar.TypeDir:
return os.MkdirAll(abs, 0o755)
if err := os.MkdirAll(abs, 0o755); err != nil {
return err
}
meta.Entries[rel] = FileMeta{Uid: hdr.Uid, Gid: hdr.Gid, Mode: hdr.Mode, Type: tar.TypeDir}
return nil
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return err
@ -127,7 +172,11 @@ func applyEntry(tr *tar.Reader, hdr *tar.Header, dest string) error {
_ = f.Close()
return err
}
return f.Close()
if err := f.Close(); err != nil {
return err
}
meta.Entries[rel] = FileMeta{Uid: hdr.Uid, Gid: hdr.Gid, Mode: hdr.Mode, Type: tar.TypeReg}
return nil
case tar.TypeSymlink:
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return err
@ -148,7 +197,11 @@ func applyEntry(tr *tar.Reader, hdr *tar.Header, dest string) error {
if err := os.RemoveAll(abs); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return os.Symlink(hdr.Linkname, abs)
if err := os.Symlink(hdr.Linkname, abs); err != nil {
return err
}
meta.Entries[rel] = FileMeta{Uid: hdr.Uid, Gid: hdr.Gid, Mode: hdr.Mode, Type: tar.TypeSymlink}
return nil
case tar.TypeLink:
// Hardlink: target must already exist inside dest from this or
// a previous layer, and must not escape.