The no-seed branch used to mount the base rootfs read-only, mount the freshly mkfs'd work disk read-write, sudo-cp /root from one to the other, then flatten any accidental /root/root/ nesting. Five sudo call sites packed into a fallback that the common image path doesn't even exercise. Replace with: `mkfs.ext4 -F -E root_owner=0:0` and nothing else. mkfs already stamps inode 2 as root:root:0755 — sshd's StrictModes walks that dir's ownership when the work disk mounts at /root in the guest, so getting it right from mkfs means authsync can just write authorized_keys without any repair pass. Tradeoff: no-seed VMs lose the base rootfs's default /root dotfiles (.bashrc, .profile). The no-seed path is explicitly the degraded fallback — `banger doctor` already warns about it — and users who want those back have two documented knobs: rebuild the image with a work-seed, or land them via [[file_sync]]. Sudo call sites removed: 5 (MountTempDir × 2, sudo cp -a, flattenNestedWorkHome's chmod/cp/rm). flattenNestedWorkHome itself stays alive for now — authsync + image_seed still call it — and gets deleted in commit 5 once its last caller goes away. While here: fix the freshly-added EnsureExt4RootPerms helper. `set_inode_field <2> mode N` overwrites the full i_mode word instead of preserving the type nibble, so the initial implementation that passed just the permission bits (0755) would reset the fs root to regular-file shape and break the next kernel mount with "Structure needs cleaning." The corrected call OR's in S_IFDIR (0o040000) explicitly. Test updated to match. Smoke: 21/21 scenarios green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
322 lines
10 KiB
Go
322 lines
10 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 — with the
|
|
// FULL i_mode word (S_IFDIR | 0755 = 040755). debugfs's
|
|
// set_inode_field doesn't preserve the type nibble, so passing
|
|
// just the permission bits (0755) would reset the root inode
|
|
// to regular-file shape and break the next kernel mount.
|
|
if !strings.Contains(capturedScript, "set_inode_field <2> mode 040755") {
|
|
t.Fatalf("script missing root-inode mode line with S_IFDIR+0755:\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)
|
|
}
|
|
})
|
|
}
|
|
}
|