package storage import ( "context" "io" "strings" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // recordingRunner records every command it is asked to run (and never execs anything). The // adversarial matrix asserts that a rejected argument means ZERO commands were constructed — // the validator is the wall, not the exec. type recordingRunner struct { calls [][]string err error } func (r *recordingRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) { return r.Run(ctx, name, args...) } func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) { r.calls = append(r.calls, append([]string{name}, args...)) return nil, nil, r.err } // --- The headline: the arg-validator adversarial matrix. --- func TestValidateUUID_AdversarialMatrix(t *testing.T) { good := []string{ "0fc63daf-8483-4772-8e79-3d69d8477de4", // ext4 "1234-ABCD", // FAT "deadbeefdeadbeef", // NTFS-ish 16 hex } for _, u := range good { if err := ValidateUUID(u); err != nil { t.Errorf("ValidateUUID(%q) rejected a valid UUID: %v", u, err) } } bad := []string{ "", // empty "../../etc/shadow", // traversal "abcd; rm -rf /", // shell metacharacters "abcd$(reboot)", // command substitution "abcd`reboot`", // backticks "abcd&whoami", // & "abcd|cat", // pipe "abcd\nreboot", // newline "abcd /dev/sda", // space + extra arg "g00dlooking-but-z-not-hex", // non-hex "/dev/disk/by-uuid/abcd", // a path, not a uuid strings.Repeat("a", maxUUIDLen+1), // too long "abcd\x00", // NUL } for _, u := range bad { if err := ValidateUUID(u); err == nil { t.Errorf("ValidateUUID(%q) ACCEPTED a hostile UUID", u) } } } func TestValidateMountPath_AdversarialMatrix(t *testing.T) { good := []string{"/mnt/usb-backup", "/srv/felhom/bulk", "/mnt/data_1"} for _, p := range good { if err := ValidateMountPath(p); err != nil { t.Errorf("ValidateMountPath(%q) rejected a valid path: %v", p, err) } } bad := []string{ "", // empty "relative/path", // not absolute "/", // bare root "/mnt/../etc", // traversal "/mnt/./x", // dot segment "/mnt/usb backup", // space "/mnt/usb;reboot", // metacharacter "/mnt/$(reboot)", // command substitution "/mnt/x\nWhat=/dev/sda", // newline → unit-file injection attempt "/mnt/x\x00", // NUL "/mnt/x`reboot`", // backticks } for _, p := range bad { if err := ValidateMountPath(p); err == nil { t.Errorf("ValidateMountPath(%q) ACCEPTED a hostile path", p) } } } func TestValidateSMARTDevice_AdversarialMatrix(t *testing.T) { good := []string{"/dev/sda", "/dev/sdb", "/dev/nvme0n1", "/dev/vda"} for _, d := range good { if err := ValidateSMARTDevice(d); err != nil { t.Errorf("ValidateSMARTDevice(%q) rejected a valid device: %v", d, err) } } bad := []string{ "/dev/sda1", // a partition, not the whole disk (smartctl targets the disk) "/dev/../etc/shadow", // traversal "/dev/sda;reboot", // metacharacter "/dev/sda /dev/sdb", // extra arg "/etc/passwd", // not /dev "sda", // no /dev prefix "/dev/mapper/pve-root", // device-mapper not whitelisted "", // empty } for _, d := range bad { if err := ValidateSMARTDevice(d); err == nil { t.Errorf("ValidateSMARTDevice(%q) ACCEPTED a hostile device", d) } } } func TestValidateLVMName_AdversarialMatrix(t *testing.T) { for _, n := range []string{"pve", "data", "vg0", "vg.thin_pool"} { if err := ValidateLVMName(n); err != nil { t.Errorf("ValidateLVMName(%q) rejected a valid name: %v", n, err) } } for _, n := range []string{"", "-rf", "vg;reboot", "vg/pool extra", "vg\nx", "vg$(x)"} { if err := ValidateLVMName(n); err == nil { t.Errorf("ValidateLVMName(%q) ACCEPTED a hostile name", n) } } } // TestHostOps_RejectsHostileArgsBeforeExec is the proof that validation happens BEFORE any // command is constructed: a hostile UUID / mount path / device → error AND zero runner calls. func TestHostOps_RejectsHostileArgsBeforeExec(t *testing.T) { ctx := context.Background() t.Run("EnsureMount hostile UUID", func(t *testing.T) { rr := &recordingRunner{} ops := newTestHostOps(rr) err := ops.EnsureMount(ctx, MountSpec{Name: "x", UUID: "abcd; rm -rf /", Where: "/mnt/x"}) if err == nil { t.Fatal("expected rejection") } if len(rr.calls) != 0 { t.Fatalf("a hostile UUID must be refused before any exec; got calls %v", rr.calls) } }) t.Run("EnsureMount traversal mountpoint", func(t *testing.T) { rr := &recordingRunner{} ops := newTestHostOps(rr) err := ops.EnsureMount(ctx, MountSpec{Name: "x", UUID: "1234-ABCD", Where: "/mnt/../etc"}) if err == nil || len(rr.calls) != 0 { t.Fatalf("traversal mountpoint must be refused before exec; err=%v calls=%v", err, rr.calls) } }) t.Run("EnsureMount injection via mount options", func(t *testing.T) { rr := &recordingRunner{} ops := newTestHostOps(rr) err := ops.EnsureMount(ctx, MountSpec{Name: "x", UUID: "1234-ABCD", Where: "/mnt/x", Options: "ro\nWhat=/dev/sda"}) if err == nil || len(rr.calls) != 0 { t.Fatalf("newline-injecting options must be refused before exec; err=%v calls=%v", err, rr.calls) } }) t.Run("SMART hostile device", func(t *testing.T) { rr := &recordingRunner{} ops := newTestHostOps(rr) _, err := ops.SMART(ctx, "/dev/sda;reboot") if err == nil || len(rr.calls) != 0 { t.Fatalf("hostile smart device must be refused before exec; err=%v calls=%v", err, rr.calls) } }) t.Run("ThinPoolMetadata hostile vg", func(t *testing.T) { rr := &recordingRunner{} ops := newTestHostOps(rr) if _, ok := ops.ThinPoolMetadata(ctx, "vg;reboot", "data"); ok { t.Fatal("hostile vg must return ok=false") } if len(rr.calls) != 0 { t.Fatalf("hostile vg must be refused before exec; calls=%v", rr.calls) } }) } // newTestHostOps builds a SudoHostOps over a recording runner with a temp stage dir (so the // EnsureMount staging write — which happens AFTER validation — has somewhere to go in the // rare valid-path test; hostile-path tests never reach it). func newTestHostOps(rr proxmox.Runner) *SudoHostOps { return NewSudoHostOps(SudoHostOpsConfig{ Runner: rr, UnitDir: "/tmp/felhom-test-units", StageDir: testStageDir(), Logger: quietLogger(), }) } func TestSystemdEscapePath(t *testing.T) { cases := map[string]string{ "/mnt/usb-backup": "mnt-usb\\x2dbackup", "/var/lib/vz": "var-lib-vz", "/srv/data": "srv-data", "/": "-", "/etc/foo.conf": "etc-foo.conf", } for in, want := range cases { if got := systemdEscapePath(in); got != want { t.Errorf("systemdEscapePath(%q) = %q, want %q", in, got, want) } } // The unit name is derived deterministically and ends in .mount. name, err := UnitNameForMount("/mnt/usb-backup") if err != nil || !strings.HasSuffix(name, ".mount") { t.Errorf("UnitNameForMount = %q, %v", name, err) } }