banger/internal/imagepull/ownership.go
Thales Maciel d743a8ba4b
daemon: persist teardown fallbacks and reject unsafe import paths
Preserve cleanup after daemon restarts and harden OCI and tar imports
against filenames that debugfs cannot encode safely.

Mirror tap, loop, and dm teardown identity onto VM.Runtime, teach
cleanup and reconcile to fall back to those persisted fields when
handles.json is missing or corrupt, and clear the recovery state on
stop, error, and delete paths.

Reject debugfs-hostile entry names during flattening and in
ApplyOwnership itself, then add regression coverage for corrupt
handles.json recovery and unsafe import paths.

Verified with targeted go tests, make lint-go, make lint-shell, and
make build.
2026-04-23 16:21:59 -03:00

124 lines
3.7 KiB
Go

package imagepull
import (
"archive/tar"
"bytes"
"context"
"errors"
"fmt"
"sort"
"strings"
"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, err := buildOwnershipScript(meta)
if err != nil {
return err
}
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, error) {
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)
}
if err := validateDebugFSPath(p); err != nil {
return nil, err
}
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, nil
}
// 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
}
}
var errUnsafeDebugFSPath = errors.New("unsafe path for debugfs ownership script")
func validateDebugFSPath(rel string) error {
for i := 0; i < len(rel); i++ {
switch c := rel[i]; {
case c == '"':
return fmt.Errorf("%w: %q contains '\"'", errUnsafeDebugFSPath, rel)
case c == '\\':
return fmt.Errorf("%w: %q contains '\\\\'", errUnsafeDebugFSPath, rel)
case c < 0x20 || c == 0x7f:
return fmt.Errorf("%w: %q contains control byte 0x%02x", errUnsafeDebugFSPath, rel, c)
}
}
return nil
}
// escapeDebugfsPath prepends "/" and wraps in double quotes if the path
// contains spaces. validateDebugFSPath rejects debugfs-hostile bytes
// before this runs, so the only quoting we need is the simple
// whitespace case debugfs already handles.
func escapeDebugfsPath(rel string) string {
abs := "/" + rel
if strings.ContainsRune(abs, ' ') {
return `"` + abs + `"`
}
return abs
}