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:
parent
2e4d4b14da
commit
43982a4ae3
7 changed files with 334 additions and 32 deletions
114
internal/imagepull/ownership.go
Normal file
114
internal/imagepull/ownership.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package imagepull
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"banger/internal/system"
|
||||
)
|
||||
|
||||
// ApplyOwnership rewrites the ext4 image's per-file uid/gid/mode to match
|
||||
// the tar-header values Flatten captured. `mkfs.ext4 -d` preserves the
|
||||
// on-disk ownership of the source tree — which is the runner's uid/gid,
|
||||
// since we extracted as a regular user — so without this pass setuid
|
||||
// binaries become setuid-nonroot and root-owned config files are
|
||||
// readable by the runner's group.
|
||||
//
|
||||
// Implementation: stream a "set_inode_field" script to `debugfs -w`.
|
||||
// One invocation handles tens of thousands of files; the bottleneck is
|
||||
// debugfs's one-inode-at-a-time disk I/O, not process startup.
|
||||
func ApplyOwnership(ctx context.Context, runner system.CommandRunner, ext4File string, meta Metadata) error {
|
||||
if len(meta.Entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
script := buildOwnershipScript(meta)
|
||||
if script.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
stdinRunner, ok := runner.(system.StdinRunner)
|
||||
if !ok {
|
||||
return fmt.Errorf("ownership fixup requires a runner that supports stdin (got %T)", runner)
|
||||
}
|
||||
out, err := stdinRunner.RunStdin(ctx, script, "debugfs", "-w", "-f", "-", ext4File)
|
||||
if err != nil {
|
||||
return fmt.Errorf("debugfs ownership fixup: %w: %s", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildOwnershipScript emits one `set_inode_field` block per entry.
|
||||
// Paths are prefixed with "/" so debugfs resolves them from the ext4
|
||||
// root. Entries are sorted for deterministic output (helps testing and
|
||||
// makes debugfs's internal caching slightly more cache-friendly).
|
||||
func buildOwnershipScript(meta Metadata) *bytes.Buffer {
|
||||
var buf bytes.Buffer
|
||||
paths := make([]string, 0, len(meta.Entries))
|
||||
for p := range meta.Entries {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, p := range paths {
|
||||
m := meta.Entries[p]
|
||||
mode := debugfsMode(m.Type, m.Mode)
|
||||
if mode == 0 {
|
||||
continue // hardlinks or unsupported types (skip)
|
||||
}
|
||||
escaped := escapeDebugfsPath(p)
|
||||
fmt.Fprintf(&buf, "set_inode_field %s uid %d\n", escaped, m.Uid)
|
||||
fmt.Fprintf(&buf, "set_inode_field %s gid %d\n", escaped, m.Gid)
|
||||
fmt.Fprintf(&buf, "set_inode_field %s mode 0%o\n", escaped, mode)
|
||||
}
|
||||
return &buf
|
||||
}
|
||||
|
||||
// debugfsMode composes the full i_mode word (file-type bits +
|
||||
// permission bits) that debugfs' `set_inode_field ... mode` expects.
|
||||
// Returns 0 for types we don't set (hardlinks, unknown).
|
||||
func debugfsMode(typ byte, hdrMode int64) uint32 {
|
||||
perm := uint32(hdrMode) & 0o7777
|
||||
switch typ {
|
||||
case tar.TypeReg:
|
||||
return 0o100000 | perm
|
||||
case tar.TypeDir:
|
||||
return 0o040000 | perm
|
||||
case tar.TypeSymlink:
|
||||
return 0o120000 | perm
|
||||
case tar.TypeChar:
|
||||
return 0o020000 | perm
|
||||
case tar.TypeBlock:
|
||||
return 0o060000 | perm
|
||||
case tar.TypeFifo:
|
||||
return 0o010000 | perm
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// escapeDebugfsPath prepends "/" and wraps in double quotes if the path
|
||||
// contains whitespace or special characters. debugfs' quoting is
|
||||
// minimal; for safety we reject backslashes/quotes in paths entirely.
|
||||
func escapeDebugfsPath(rel string) string {
|
||||
abs := "/" + rel
|
||||
// Container images don't normally use quoting-hostile chars; if they
|
||||
// do, fall back to the raw path and hope debugfs copes (it usually
|
||||
// does for spaces when quoted).
|
||||
needsQuote := false
|
||||
for _, c := range abs {
|
||||
switch c {
|
||||
case ' ', '\t':
|
||||
needsQuote = true
|
||||
case '"', '\\', '\n':
|
||||
// Deliberately unhandled; debugfs may fail on these.
|
||||
// Returning the raw string gives us a visible error
|
||||
// instead of a silently-corrupted script.
|
||||
return abs
|
||||
}
|
||||
}
|
||||
if needsQuote {
|
||||
return `"` + abs + `"`
|
||||
}
|
||||
return abs
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue