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>
This commit is contained in:
parent
d743a8ba4b
commit
77043966d4
2 changed files with 612 additions and 0 deletions
294
internal/system/ext4.go
Normal file
294
internal/system/ext4.go
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
318
internal/system/ext4_test.go
Normal file
318
internal/system/ext4_test.go
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue