banger/internal/system/ext4_test.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

318 lines
9.7 KiB
Go

package system
import (
"bytes"
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// stdinFuncRunner is funcRunner extended with a RunStdin hook so we
// can assert the exact debugfs batch script that callers stream in.
type stdinFuncRunner struct {
funcRunner
runStdin func(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error)
}
func (r stdinFuncRunner) RunStdin(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error) {
if r.runStdin == nil {
return nil, errors.New("unexpected RunStdin call")
}
return r.runStdin(ctx, stdin, name, args...)
}
// userOwnedImage writes a zero-length regular file at a tempdir and
// returns its path. Regular files trigger extfsRun's non-sudo branch,
// which is the whole point of the new toolkit.
func userOwnedImage(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "work.ext4")
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
t.Fatalf("write image: %v", err)
}
return path
}
func TestExt4PathExists(t *testing.T) {
image := userOwnedImage(t)
t.Run("path found", func(t *testing.T) {
r := funcRunner{
run: func(_ context.Context, name string, args ...string) ([]byte, error) {
if name != "debugfs" {
t.Fatalf("name = %q, want debugfs", name)
}
want := []string{"-R", "stat /root/.ssh", image}
for i := range want {
if args[i] != want[i] {
t.Fatalf("args[%d] = %q, want %q (full %v)", i, args[i], want[i], args)
}
}
return []byte("Inode: 12 Type: directory"), nil
},
}
ok, err := Ext4PathExists(context.Background(), r, image, "/root/.ssh")
if err != nil {
t.Fatalf("Ext4PathExists: %v", err)
}
if !ok {
t.Fatal("expected exists = true")
}
})
t.Run("path missing", func(t *testing.T) {
r := funcRunner{
run: func(context.Context, string, ...string) ([]byte, error) {
// debugfs prints the "File not found" message to stdout
// on lookup miss. No exit error (debugfs exits 0 for
// soft misses on `stat`).
return []byte("stat: File not found by ext2_lookup while starting pathname"), nil
},
}
ok, err := Ext4PathExists(context.Background(), r, image, "/root/.ssh")
if err != nil {
t.Fatalf("Ext4PathExists: %v", err)
}
if ok {
t.Fatal("expected exists = false")
}
})
t.Run("rejects hostile path", func(t *testing.T) {
r := funcRunner{}
if _, err := Ext4PathExists(context.Background(), r, image, `/evil"path`); err == nil {
t.Fatal("expected rejection for path containing double-quote")
}
})
}
func TestReadExt4File(t *testing.T) {
image := userOwnedImage(t)
r := funcRunner{
run: func(_ context.Context, name string, args ...string) ([]byte, error) {
if name != "debugfs" {
t.Fatalf("name = %q, want debugfs", name)
}
if args[0] != "-R" || args[1] != "cat /etc/fstab" {
t.Fatalf("args = %v, want -R \"cat /etc/fstab\" ...", args)
}
return []byte("tmpfs /tmp tmpfs defaults 0 0\n"), nil
},
}
got, err := ReadExt4File(context.Background(), r, image, "/etc/fstab")
if err != nil {
t.Fatalf("ReadExt4File: %v", err)
}
if !bytes.Contains(got, []byte("tmpfs /tmp")) {
t.Fatalf("got = %q, want contains tmpfs line", got)
}
}
func TestMkdirExt4_BatchesStatMkdirAndMetadata(t *testing.T) {
image := userOwnedImage(t)
var capturedScript string
r := stdinFuncRunner{
funcRunner: funcRunner{
run: func(_ context.Context, name string, args ...string) ([]byte, error) {
// The only non-stdin call should be the existence check.
if name == "debugfs" && len(args) >= 2 && args[0] == "-R" && strings.HasPrefix(args[1], "stat ") {
return []byte("stat: File not found"), nil
}
t.Fatalf("unexpected Run(%q, %v)", name, args)
return nil, nil
},
},
runStdin: func(_ context.Context, stdin io.Reader, name string, args ...string) ([]byte, error) {
if name != "debugfs" {
t.Fatalf("stdin runner name = %q, want debugfs", name)
}
want := []string{"-w", "-f", "-", image}
for i, w := range want {
if args[i] != w {
t.Fatalf("stdin args[%d] = %q, want %q", i, args[i], w)
}
}
b, _ := io.ReadAll(stdin)
capturedScript = string(b)
return nil, nil
},
}
if err := MkdirExt4(context.Background(), r, image, "/.ssh", 0o700, 0, 0); err != nil {
t.Fatalf("MkdirExt4: %v", err)
}
// mkdir line must be present (path didn't exist).
if !strings.Contains(capturedScript, "mkdir /.ssh") {
t.Fatalf("script missing mkdir line:\n%s", capturedScript)
}
// Mode must include the directory file-type nibble (040000 | 0700 = 040700).
if !strings.Contains(capturedScript, "set_inode_field /.ssh mode 040700") {
t.Fatalf("script missing mode line with S_IFDIR+0700:\n%s", capturedScript)
}
if !strings.Contains(capturedScript, "set_inode_field /.ssh uid 0") {
t.Fatalf("script missing uid line:\n%s", capturedScript)
}
if !strings.Contains(capturedScript, "set_inode_field /.ssh gid 0") {
t.Fatalf("script missing gid line:\n%s", capturedScript)
}
}
func TestMkdirExt4_SkipsMkdirWhenDirectoryExists(t *testing.T) {
image := userOwnedImage(t)
var capturedScript string
r := stdinFuncRunner{
funcRunner: funcRunner{
run: func(_ context.Context, name string, args ...string) ([]byte, error) {
// First call: existence probe. Return success.
if name == "debugfs" && args[0] == "-R" && strings.HasPrefix(args[1], "stat ") {
return []byte("Inode: 12 Type: directory Mode: 0700"), nil
}
t.Fatalf("unexpected Run(%q, %v)", name, args)
return nil, nil
},
},
runStdin: func(_ context.Context, stdin io.Reader, _ string, _ ...string) ([]byte, error) {
b, _ := io.ReadAll(stdin)
capturedScript = string(b)
return nil, nil
},
}
if err := MkdirExt4(context.Background(), r, image, "/.ssh", 0o700, 0, 0); err != nil {
t.Fatalf("MkdirExt4: %v", err)
}
// Directory existed — no mkdir line, but metadata lines still fire.
if strings.Contains(capturedScript, "mkdir ") {
t.Fatalf("script should not contain mkdir for pre-existing path:\n%s", capturedScript)
}
if !strings.Contains(capturedScript, "set_inode_field /.ssh mode") {
t.Fatalf("script missing metadata reset for pre-existing dir:\n%s", capturedScript)
}
}
func TestWriteExt4FileOwned_StagesTempfileAndBatchesOwnership(t *testing.T) {
image := userOwnedImage(t)
var observedTemp string
var capturedScript string
r := stdinFuncRunner{
funcRunner: funcRunner{
run: func(_ context.Context, name string, args ...string) ([]byte, error) {
switch name {
case "e2rm":
// First non-stdin call — best-effort, we don't
// verify the target since e2rm on a missing path
// returns a visible error but the caller ignores it.
return nil, nil
case "e2cp":
if len(args) != 2 {
t.Fatalf("e2cp args = %v, want 2 (src, dst)", args)
}
observedTemp = args[0]
// Assert the dst uses the image:path form.
if args[1] != image+":/root/.ssh/authorized_keys" {
t.Fatalf("e2cp dst = %q, want image:path", args[1])
}
// Assert the temp file was populated with our data
// BEFORE e2cp was called.
data, err := os.ReadFile(args[0])
if err != nil {
t.Fatalf("temp missing at e2cp time: %v", err)
}
if !bytes.Equal(data, []byte("managed-key\n")) {
t.Fatalf("temp contents = %q, want managed-key", data)
}
return nil, nil
}
t.Fatalf("unexpected Run(%q, %v)", name, args)
return nil, nil
},
},
runStdin: func(_ context.Context, stdin io.Reader, _ string, _ ...string) ([]byte, error) {
b, _ := io.ReadAll(stdin)
capturedScript = string(b)
return nil, nil
},
}
err := WriteExt4FileOwned(
context.Background(), r, image,
"/root/.ssh/authorized_keys",
0o600, 0, 0,
[]byte("managed-key\n"),
)
if err != nil {
t.Fatalf("WriteExt4FileOwned: %v", err)
}
// Temp cleanup ran — we saved observedTemp while it still existed;
// by now it should be gone.
if observedTemp == "" {
t.Fatal("e2cp source path was never captured")
}
if _, err := os.Stat(observedTemp); !os.IsNotExist(err) {
t.Fatalf("temp file not cleaned up: stat err = %v", err)
}
// Mode line must bake in S_IFREG (0100000) + 0600 = 0100600.
if !strings.Contains(capturedScript, "set_inode_field /root/.ssh/authorized_keys mode 0100600") {
t.Fatalf("script missing regular-file mode line:\n%s", capturedScript)
}
}
func TestEnsureExt4RootPerms_UsesRootInodeLiteral(t *testing.T) {
image := userOwnedImage(t)
var capturedScript string
r := stdinFuncRunner{
funcRunner: funcRunner{},
runStdin: func(_ context.Context, stdin io.Reader, _ string, _ ...string) ([]byte, error) {
b, _ := io.ReadAll(stdin)
capturedScript = string(b)
return nil, nil
},
}
if err := EnsureExt4RootPerms(context.Background(), r, image, 0o755, 0, 0); err != nil {
t.Fatalf("EnsureExt4RootPerms: %v", err)
}
// Must address inode 2 — the ext4 root directory.
if !strings.Contains(capturedScript, "sif <2> mode 0755") {
t.Fatalf("script missing root-inode mode line:\n%s", capturedScript)
}
if !strings.Contains(capturedScript, "set_inode_field <2> uid 0") {
t.Fatalf("script missing root-inode uid line:\n%s", capturedScript)
}
}
func TestRejectDebugfsUnsafePath(t *testing.T) {
for _, tc := range []struct {
name string
path string
wantErr bool
}{
{"empty", "", true},
{"relative", "relative/path", true},
{"absolute plain", "/ok", false},
{"absolute with space", "/ok path", false},
{"contains double-quote", `/a"b`, true},
{"contains backslash", `/a\b`, true},
{"contains newline", "/a\nb", true},
} {
t.Run(tc.name, func(t *testing.T) {
err := rejectDebugfsUnsafePath(tc.path)
if (err != nil) != tc.wantErr {
t.Fatalf("rejectDebugfsUnsafePath(%q) err = %v, wantErr = %v", tc.path, err, tc.wantErr)
}
})
}
}