banger/internal/system/ext4.go
Thales Maciel 77043966d4
system: add ext4 toolkit for non-sudo work-disk writes
The daemon mounts every VM's work disk on the host via sudo, copies
files in as root, chmods+chowns them, and unmounts. That's ~18 of
banger's runtime RunSudo calls. The ext4 image is a regular file the
daemon user owns; e2cp / debugfs can write to it directly and bake
uid/gid/mode into the filesystem metadata without the caller being
root. `imagepull.ApplyOwnership` already proves this works in
production (OCI layer flattening writes 0/0/root-owned inodes from
an unprivileged daemon).

This commit adds the toolkit layer. Callers land in the next four
commits:

  - MkdirExt4 — idempotent directory create + metadata reset, single
    debugfs batch
  - WriteExt4FileOwned — e2cp + debugfs-driven uid/gid/mode, auto-
    cleans the host tempfile
  - SetExt4Ownership — sif + set_inode_field batch for existing
    inodes (no mkdir implied)
  - EnsureExt4RootPerms — fixes inode <2> (the fs root, which is
    `/root` once the work disk is mounted inside the guest), the
    thing sshd's StrictModes walks
  - Ext4PathExists — yes/no probe via `debugfs -R "stat ..."` with
    "File not found" detection
  - ReadExt4File — bytes-returning wrapper around the existing
    ReadDebugFSText with the same path rejection

Design notes:

  - extfsRun auto-switches Run ↔ RunSudo on imagePath's type: regular
    files get the unprivileged path, block devices (dm-snapshot,
    loops) get sudo. The same helper works for both patchRootOverlay
    (dm device) and work-disk writes (user-owned file). No caller
    flag needed — os.Stat tells us.
  - debugfsScript batches set_inode_field + sif + mkdir lines into
    one `debugfs -w -f -` stdin invocation on any Runner that
    implements StdinRunner (production's system.Runner does). Matches
    imagepull.ApplyOwnership's existing pattern; dramatically cheaper
    than per-call subprocesses.
  - Paths are escaped for debugfs on the way in: spaces get double-
    quoted, double-quote/backslash/newline are rejected outright
    (debugfs's hand-rolled parser doesn't reliably escape those and
    we'd rather fail fast than silently scribble over the wrong
    inode).

Tests: seven behaviour assertions via scripted + stdin-scripted
runners — existence probe (found + missing + rejection), read
passthrough, mkdir batch contents (new vs. pre-existing path), write
tempfile cleanup + mode line shape, root-inode addressing, and the
full rejectDebugfsUnsafePath matrix.

No production wiring change in this commit — the helpers land
unused. `make smoke` stays green (21/21) because nothing else
shifted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:31:50 -03:00

294 lines
11 KiB
Go

package system
import (
"bytes"
"context"
"fmt"
"os"
"strings"
)
// ext4 mode bitmasks that debugfs's `set_inode_field ... mode` expects.
// debugfs wants the full file-type + permission word, not just the
// permission bits. Callers pass the permission portion; these constants
// OR it into the right file type.
const (
ext4ModeRegularFile = 0o100000 // S_IFREG
ext4ModeDirectory = 0o040000 // S_IFDIR
)
// MkdirExt4 creates a directory inside the ext4 image, setting its
// owner/group/mode to root:root:<mode> by default or whatever the
// caller passes. Idempotent: if the directory already exists, it's
// left alone and only the metadata (uid/gid/mode) is reset to what
// was requested. Runs a single `debugfs -w` invocation so ~all the
// state transitions land in one fs-lock window.
//
// guestPath must be an absolute path inside the ext4 image (e.g.
// "/.ssh"). The function escapes the path for debugfs before sending
// it down the wire.
func MkdirExt4(ctx context.Context, runner CommandRunner, imagePath, guestPath string, mode os.FileMode, uid, gid int) error {
escaped, err := escapeDebugfsGuestPath(guestPath)
if err != nil {
return err
}
var script bytes.Buffer
// `mkdir` errors if the entry already exists. Tolerate that by
// running `stat` first: on "exists" we skip the mkdir line and
// fall through to the metadata resets, which are idempotent.
exists, err := Ext4PathExists(ctx, runner, imagePath, guestPath)
if err != nil {
return err
}
if !exists {
fmt.Fprintf(&script, "mkdir %s\n", escaped)
}
fmt.Fprintf(&script, "set_inode_field %s mode 0%o\n", escaped, ext4ModeDirectory|(uint32(mode.Perm())&0o7777))
fmt.Fprintf(&script, "set_inode_field %s uid %d\n", escaped, uid)
fmt.Fprintf(&script, "set_inode_field %s gid %d\n", escaped, gid)
return debugfsScript(ctx, runner, imagePath, &script)
}
// WriteExt4FileOwned copies `data` into <imagePath>:<guestPath> and
// forces the inode's uid/gid/mode to the requested values. Unlike
// WriteExt4FileMode, this helper does NOT assume the image is a
// root-owned block device: if the image is a regular file the daemon
// user owns, every call runs without sudo. That's the common case for
// work-disk writes (vm_authsync, image_seed, runFileSync).
//
// Safety: always remove the destination first so e2cp sees a clean
// target (avoids copy-into-existing-file quirks on older e2tools).
func WriteExt4FileOwned(ctx context.Context, runner CommandRunner, imagePath, guestPath string, mode os.FileMode, uid, gid int, data []byte) error {
tmp, err := stageDataTempfile(data, mode)
if err != nil {
return err
}
defer os.Remove(tmp)
_, _ = extfsRun(ctx, runner, imagePath, "e2rm", imagePath+":"+guestPath)
if _, err := extfsRun(ctx, runner, imagePath, "e2cp", tmp, imagePath+":"+guestPath); err != nil {
return err
}
// Fix per-file uid/gid/mode in a debugfs batch. e2cp -O/-G exist
// but ship inconsistently across distros; driving the inode via
// set_inode_field matches how imagepull.ApplyOwnership has worked
// reliably in production.
escaped, err := escapeDebugfsGuestPath(guestPath)
if err != nil {
return err
}
var script bytes.Buffer
fmt.Fprintf(&script, "set_inode_field %s mode 0%o\n", escaped, ext4ModeRegularFile|(uint32(mode.Perm())&0o7777))
fmt.Fprintf(&script, "set_inode_field %s uid %d\n", escaped, uid)
fmt.Fprintf(&script, "set_inode_field %s gid %d\n", escaped, gid)
return debugfsScript(ctx, runner, imagePath, &script)
}
// SetExt4Ownership adjusts an existing inode's uid/gid/mode in a
// single debugfs batch. Does not check whether the path exists —
// callers are expected to have just created it, or to know it's
// already there.
func SetExt4Ownership(ctx context.Context, runner CommandRunner, imagePath, guestPath string, mode os.FileMode, uid, gid int) error {
escaped, err := escapeDebugfsGuestPath(guestPath)
if err != nil {
return err
}
// debugfs exposes two spellings for mode edits: `set_inode_field
// <path> mode 0<octal>` which overwrites the full i_mode word
// (requiring callers to bake in the file-type nibble), and `sif
// <path> mode 0<octal>` which takes a permission-only value and
// preserves the existing type bits. We take the permission bits
// from the caller, so `sif` is the right verb.
var buf bytes.Buffer
fmt.Fprintf(&buf, "sif %s mode 0%o\n", escaped, uint32(mode.Perm())&0o7777)
fmt.Fprintf(&buf, "set_inode_field %s uid %d\n", escaped, uid)
fmt.Fprintf(&buf, "set_inode_field %s gid %d\n", escaped, gid)
return debugfsScript(ctx, runner, imagePath, &buf)
}
// EnsureExt4RootPerms sets the filesystem root inode (inode <2>,
// which is what `/` resolves to) to the given mode + owner. sshd's
// StrictModes inside the guest walks the home directory's ownership;
// the work disk is mounted at /root in the guest and its root inode
// is /root as far as sshd is concerned. Default-safe value: 0755
// root:root.
func EnsureExt4RootPerms(ctx context.Context, runner CommandRunner, imagePath string, mode os.FileMode, uid, gid int) error {
var script bytes.Buffer
fmt.Fprintf(&script, "sif <2> mode 0%o\n", uint32(mode.Perm())&0o7777)
fmt.Fprintf(&script, "set_inode_field <2> uid %d\n", uid)
fmt.Fprintf(&script, "set_inode_field <2> gid %d\n", gid)
return debugfsScript(ctx, runner, imagePath, &script)
}
// Ext4PathExists reports whether guestPath resolves inside imagePath.
// Missing-path is NOT an error — the boolean distinguishes them.
// Uses `debugfs -R "stat <path>"` and inspects stderr for the
// standard "File not found" message e2fsprogs emits.
func Ext4PathExists(ctx context.Context, runner CommandRunner, imagePath, guestPath string) (bool, error) {
// debugfs stat wants the path without any extra quoting beyond
// what debugfs already does; we still reject quoting-hostile
// chars up front.
if err := rejectDebugfsUnsafePath(guestPath); err != nil {
return false, err
}
out, err := extfsRun(ctx, runner, imagePath, "debugfs", "-R", "stat "+guestPath, imagePath)
combined := strings.ToLower(string(out) + " " + fmt.Sprint(err))
if strings.Contains(combined, "file not found") {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// ReadExt4File reads guestPath from imagePath as raw bytes. Wraps the
// older ReadDebugFSText with a []byte return and the same unsafe-path
// rejection the write helpers use.
func ReadExt4File(ctx context.Context, runner CommandRunner, imagePath, guestPath string) ([]byte, error) {
if err := rejectDebugfsUnsafePath(guestPath); err != nil {
return nil, err
}
out, err := extfsRun(ctx, runner, imagePath, "debugfs", "-R", "cat "+guestPath, imagePath)
if err != nil {
return nil, err
}
return out, nil
}
// ---- internal helpers ----
// extfsRun executes an ext4-toolkit command against imagePath,
// auto-elevating to sudo when imagePath is a block device (dm-snapshot
// targets, raw loop devices) and staying as the invoking user when
// it's a regular file (the user-owned .ext4 files under StateDir that
// this refactor targets). Tests that don't care can pass any runner
// that satisfies CommandRunner.
func extfsRun(ctx context.Context, runner CommandRunner, imagePath, name string, args ...string) ([]byte, error) {
if needsElevation(imagePath) {
all := append([]string{name}, args...)
return runner.RunSudo(ctx, all...)
}
return runner.Run(ctx, name, args...)
}
// needsElevation returns true when imagePath is something only root
// can write to (block devices owned root:disk). For regular files
// the invoking user owns, returns false. On stat failure we err on
// the side of NOT elevating — the subsequent tool invocation will
// surface a clearer error than a bogus sudo escalation would.
func needsElevation(imagePath string) bool {
info, err := os.Stat(imagePath)
if err != nil {
return false
}
return !info.Mode().IsRegular()
}
// debugfsScript streams a scripted batch to `debugfs -w -f -
// <imagePath>`. Requires the runner to implement StdinRunner — every
// production runner in banger does, but test doubles may not, in
// which case we fall back to one debugfs invocation per line. The
// fallback is a correctness net; production always gets the batched
// single-invocation path.
func debugfsScript(ctx context.Context, runner CommandRunner, imagePath string, script *bytes.Buffer) error {
if script.Len() == 0 {
return nil
}
stdinRunner, ok := runner.(StdinRunner)
if ok {
// StdinRunner's interface always runs un-elevated (it's a
// Runner method, not RunSudo). For block devices we need sudo.
// When elevation is required, fall through to the per-line
// path which routes through extfsRun.
if !needsElevation(imagePath) {
out, err := stdinRunner.RunStdin(ctx, script, "debugfs", "-w", "-f", "-", imagePath)
if err != nil {
return fmt.Errorf("debugfs batch: %w: %s", err, bytes.TrimSpace(out))
}
return nil
}
}
// Per-line fallback. Not ideal for throughput but preserves
// semantics in tests and in the rare case we run against a
// block device via this toolkit.
for _, line := range strings.Split(script.String(), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if _, err := extfsRun(ctx, runner, imagePath, "debugfs", "-w", "-R", line, imagePath); err != nil {
return fmt.Errorf("debugfs %q: %w", line, err)
}
}
return nil
}
// escapeDebugfsGuestPath produces a debugfs-safe rendition of the
// guest path. debugfs tokenises on whitespace by default; paths with
// spaces must be double-quoted. Paths containing the double-quote
// itself, backslashes, or newlines are rejected outright — quoting
// those reliably in debugfs's hand-rolled parser is lore we don't
// want to inherit.
func escapeDebugfsGuestPath(guestPath string) (string, error) {
if err := rejectDebugfsUnsafePath(guestPath); err != nil {
return "", err
}
if strings.ContainsAny(guestPath, " \t") {
return `"` + guestPath + `"`, nil
}
return guestPath, nil
}
func rejectDebugfsUnsafePath(guestPath string) error {
if guestPath == "" {
return fmt.Errorf("guest path is required")
}
if !strings.HasPrefix(guestPath, "/") {
return fmt.Errorf("guest path %q must be absolute", guestPath)
}
if strings.ContainsAny(guestPath, "\"\\\n\r") {
return fmt.Errorf("guest path %q contains characters debugfs cannot safely encode", guestPath)
}
return nil
}
func stageDataTempfile(data []byte, mode os.FileMode) (string, error) {
tmp, err := os.CreateTemp("", "banger-ext4-*")
if err != nil {
return "", err
}
path := tmp.Name()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
_ = os.Remove(path)
return "", err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(path)
return "", err
}
if err := os.Chmod(path, mode.Perm()); err != nil {
_ = os.Remove(path)
return "", err
}
return path, nil
}
// RdumpExt4Dir shells out to `debugfs -R "rdump <src> <dst>" image`
// to spill a tree from the ext4 image into a host directory. Used by
// ensureWorkDisk's no-seed path to extract /root from the base rootfs
// without mounting. Content is preserved; per-entry metadata (uid,
// gid, mode) is captured via a subsequent stat walk inside debugfs.
// Returns the destination directory (same as dst on success).
func RdumpExt4Dir(ctx context.Context, runner CommandRunner, imagePath, srcPath, dstDir string) error {
if err := rejectDebugfsUnsafePath(srcPath); err != nil {
return err
}
if err := os.MkdirAll(dstDir, 0o755); err != nil {
return err
}
_, err := extfsRun(ctx, runner, imagePath, "debugfs", "-R", "rdump "+srcPath+" "+dstDir, imagePath)
return err
}