From 77043966d447808acbeadc1bd98abaf6dae07bae Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Thu, 23 Apr 2026 16:31:50 -0300 Subject: [PATCH] system: add ext4 toolkit for non-sudo work-disk writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/system/ext4.go | 294 ++++++++++++++++++++++++++++++++ internal/system/ext4_test.go | 318 +++++++++++++++++++++++++++++++++++ 2 files changed, 612 insertions(+) create mode 100644 internal/system/ext4.go create mode 100644 internal/system/ext4_test.go diff --git a/internal/system/ext4.go b/internal/system/ext4.go new file mode 100644 index 0000000..70eb902 --- /dev/null +++ b/internal/system/ext4.go @@ -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: 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 : 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 + // mode 0` which overwrites the full i_mode word + // (requiring callers to bake in the file-type nibble), and `sif + // mode 0` 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 "` 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 - +// `. 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 " 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 +} diff --git a/internal/system/ext4_test.go b/internal/system/ext4_test.go new file mode 100644 index 0000000..26cf2e7 --- /dev/null +++ b/internal/system/ext4_test.go @@ -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) + } + }) + } +}