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

@ -27,6 +27,14 @@ type CommandRunner interface {
RunSudo(ctx context.Context, args ...string) ([]byte, error)
}
// StdinRunner is a duck-typed extension to CommandRunner for callers
// that need to pipe stdin into a command (e.g. `debugfs -w -f -`). The
// real system.Runner implements it; test doubles don't need to unless
// they exercise this path.
type StdinRunner interface {
RunStdin(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error)
}
func NewRunner() Runner {
return Runner{}
}
@ -51,6 +59,25 @@ func (r Runner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
return r.Run(ctx, "sudo", all...)
}
// RunStdin executes name with args and pipes stdin in from the provided
// reader. Used for commands like debugfs -w that accept a scripted
// command stream on stdin.
func (Runner) RunStdin(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Stdin = stdin
if err := cmd.Run(); err != nil {
if stderr.Len() > 0 {
return stdout.Bytes(), fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
return stdout.Bytes(), err
}
return stdout.Bytes(), nil
}
func EnsureSudo(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "sudo", "-v")
cmd.Stdout = os.Stdout