From a396fa3a5cf1bff9b695146dba30035a086bddb4 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 06:05:45 +0300 Subject: [PATCH 1/7] test(host): pin native refusal boundaries Signed-off-by: NovusEdge --- internal/cli/host_other_test.go | 97 +++++++++++++++++++++++++ internal/config/config_other_test.go | 97 +++++++++++++++++++++++++ internal/hostcheck/checks_other_test.go | 39 ++++++++++ internal/hostcheck/checks_test.go | 64 ---------------- internal/hostcheck/kvm_linux_test.go | 74 +++++++++++++++++++ internal/hostops/support_test.go | 24 ++++++ internal/qemu/run_other_test.go | 52 +++++++++++++ internal/tui/host_other_test.go | 25 +++++++ 8 files changed, 408 insertions(+), 64 deletions(-) create mode 100644 internal/cli/host_other_test.go create mode 100644 internal/config/config_other_test.go create mode 100644 internal/hostcheck/checks_other_test.go create mode 100644 internal/hostcheck/kvm_linux_test.go create mode 100644 internal/hostops/support_test.go create mode 100644 internal/qemu/run_other_test.go create mode 100644 internal/tui/host_other_test.go diff --git a/internal/cli/host_other_test.go b/internal/cli/host_other_test.go new file mode 100644 index 00000000..c9391fb9 --- /dev/null +++ b/internal/cli/host_other_test.go @@ -0,0 +1,97 @@ +//go:build !linux + +package cli + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestHostDiagnosticsRunWithoutDataRootMutation(t *testing.T) { + t.Chdir(t.TempDir()) + for _, args := range [][]string{ + {"help"}, + {"version"}, + {"--json", "help"}, + {"--json", "version"}, + } { + t.Run(strings.Join(args, "-"), func(t *testing.T) { + root := filepath.Join(t.TempDir(), "stoat") + t.Setenv("STOAT_HOME", root) + var out, errOut bytes.Buffer + if code := Main(args, "test", strings.NewReader(""), &out, &errOut); code != ExitOK { + t.Fatalf("Main(%v) exit = %d, want ExitOK; stdout=%q stderr=%q", args, code, out.String(), errOut.String()) + } + if out.Len() == 0 { + t.Fatalf("Main(%v) produced no diagnostic output", args) + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("Main(%v) created STOAT_HOME %q; stat err = %v", args, root, err) + } + }) + } + + root := filepath.Join(t.TempDir(), "doctor-root") + t.Setenv("STOAT_HOME", root) + var out, errOut bytes.Buffer + code := Main([]string{"doctor"}, "test", strings.NewReader(""), &out, &errOut) + if code != ExitFail { + t.Fatalf("doctor exit = %d, want ExitFail for an unqualified host; stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + text := strings.ToLower(out.String()) + if !strings.Contains(text, runtime.GOOS+"/"+runtime.GOARCH) { + t.Fatalf("doctor output does not identify the host: %q", out.String()) + } + if !strings.Contains(text, "unsupported") && !strings.Contains(text, "unavailable") && !strings.Contains(text, "qualified") { + t.Fatalf("doctor output does not describe the native host as unsupported or unavailable: %q", out.String()) + } + for _, linuxAssumption := range []string{"/dev/kvm", "qemu-system-x86_64", "pacman", "apt", "dnf"} { + if strings.Contains(text, linuxAssumption) { + t.Errorf("doctor output suggests Linux dependency %q on %s: %q", linuxAssumption, runtime.GOOS, out.String()) + } + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("doctor created STOAT_HOME %q; stat err = %v", root, err) + } +} + +func TestUnsupportedCLICommandsNoMutation(t *testing.T) { + t.Chdir(t.TempDir()) + commands := [][]string{ + {"create", "work", "--image", "alpine.iso", "--secret", "docker.authkey"}, + {"pull", "alpine"}, + {"up", "work"}, + {"down", "work"}, + {"apply", "work"}, + {"ssh", "work"}, + {"exec", "work", "true"}, + {"cp", "/tmp/host", "work:/tmp/guest"}, + {"snapshot", "work", "checkpoint"}, + {"rm", "-y", "work"}, + {"init"}, + } + for _, args := range commands { + t.Run(strings.Join(args, "-"), func(t *testing.T) { + root := filepath.Join(t.TempDir(), "stoat") + t.Setenv("STOAT_HOME", root) + var out, errOut bytes.Buffer + code := Main(args, "test", strings.NewReader(""), &out, &errOut) + if code != ExitFail { + t.Fatalf("Main(%v) exit = %d, want ExitFail; stdout=%q stderr=%q", args, code, out.String(), errOut.String()) + } + if strings.Contains(errOut.String(), "STOAT_SECRET_") { + t.Fatalf("Main(%v) resolved a secret before the unsupported-host guard: %q", args, errOut.String()) + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("Main(%v) created STOAT_HOME %q; stat err = %v", args, root, err) + } + if _, err := os.Stat("stoat.toml"); !os.IsNotExist(err) { + t.Fatalf("Main(%v) created project file before refusing: stat err = %v", args, err) + } + }) + } +} diff --git a/internal/config/config_other_test.go b/internal/config/config_other_test.go new file mode 100644 index 00000000..90d19c03 --- /dev/null +++ b/internal/config/config_other_test.go @@ -0,0 +1,97 @@ +//go:build !linux + +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/novusedge/stoat/internal/hostops" +) + +func TestUnsupportedConfigOperationsNoMutation(t *testing.T) { + root := filepath.Join(t.TempDir(), "stoat") + t.Setenv("STOAT_HOME", root) + + if err := EnsureRoot(); !errors.Is(err, hostops.ErrUnsupported) { + t.Fatalf("EnsureRoot() = %v, want ErrUnsupported", err) + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("EnsureRoot() created %q on an unsupported host; stat err = %v", root, err) + } + + vmDir := filepath.Join(root, "work") + v := &VM{Name: "work", Dir: vmDir, Mode: "live", SSHPort: 2200} + if err := v.Save(); !errors.Is(err, hostops.ErrUnsupported) { + t.Fatalf("VM.Save() = %v, want ErrUnsupported", err) + } + if _, err := os.Stat(vmDir); !os.IsNotExist(err) { + t.Fatalf("VM.Save() created %q on an unsupported host; stat err = %v", vmDir, err) + } + + if err := os.MkdirAll(vmDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "shared", "work"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(vmDir, "vm.toml"), []byte("marker"), 0o644); err != nil { + t.Fatal(err) + } + if err := v.Delete(); !errors.Is(err, hostops.ErrUnsupported) { + t.Fatalf("VM.Delete() = %v, want ErrUnsupported", err) + } + for _, path := range []string{vmDir, filepath.Join(root, "shared", "work")} { + if _, err := os.Stat(path); err != nil { + t.Errorf("VM.Delete() changed %q on an unsupported host: %v", path, err) + } + } + got, err := os.ReadFile(filepath.Join(vmDir, "vm.toml")) + if err != nil { + t.Fatal(err) + } + if string(got) != "marker" { + t.Errorf("VM.Delete() rewrote vm.toml to %q", got) + } +} + +func TestHostConfigLoadReadsStoppedMetadataWithoutMutation(t *testing.T) { + root := filepath.Join(t.TempDir(), "stoat") + t.Setenv("STOAT_HOME", root) + vmDir := filepath.Join(root, "stopped") + if err := os.MkdirAll(vmDir, 0o755); err != nil { + t.Fatal(err) + } + vmTOML := []byte("name = \"stopped\"\nmode = \"live\"\nos = \"alpine\"\nsshport = 2200\n") + if err := os.WriteFile(filepath.Join(vmDir, "vm.toml"), vmTOML, 0o644); err != nil { + t.Fatal(err) + } + pidPath := filepath.Join(vmDir, "qemu.pid") + if err := os.WriteFile(pidPath, []byte("999999"), 0o644); err != nil { + t.Fatal(err) + } + + v, err := Load("stopped") + if err != nil { + t.Fatalf("Load(stopped): %v", err) + } + if v.Name != "stopped" || v.Mode != "live" || v.SSHPort != 2200 { + t.Fatalf("Load(stopped) = %+v, want stored metadata", v) + } + gotTOML, err := os.ReadFile(filepath.Join(vmDir, "vm.toml")) + if err != nil { + t.Fatal(err) + } + if string(gotTOML) != string(vmTOML) { + t.Errorf("Load(stopped) rewrote vm.toml: got %q, want %q", gotTOML, vmTOML) + } + gotPID, err := os.ReadFile(pidPath) + if err != nil { + t.Fatal(err) + } + if string(gotPID) != "999999" { + t.Errorf("Load(stopped) removed or rewrote stale pidfile: got %q", gotPID) + } +} diff --git a/internal/hostcheck/checks_other_test.go b/internal/hostcheck/checks_other_test.go new file mode 100644 index 00000000..705d53e2 --- /dev/null +++ b/internal/hostcheck/checks_other_test.go @@ -0,0 +1,39 @@ +//go:build !linux + +package hostcheck + +import ( + "runtime" + "strings" + "testing" +) + +func TestHostCheckReportsUnsupportedNativeHost(t *testing.T) { + checks := RunChecks(DistroArch) + if len(checks) != 1 { + t.Fatalf("RunChecks() returned %d rows: %+v; want one host-support row", len(checks), checks) + } + check := checks[0] + if check.OK { + t.Fatalf("unsupported host row is OK: %+v", check) + } + if check.Optional { + t.Fatalf("unsupported host row is optional: %+v", check) + } + if check.Name == "" { + t.Fatal("unsupported host row has no name") + } + if len(check.Fix) != 0 { + t.Fatalf("unsupported host row offers Linux package guidance: %v", check.Fix) + } + row := strings.ToLower(check.Name + " " + check.Detail) + for _, linuxAssumption := range []string{"/dev/kvm", "qemu-system-x86_64", "pacman", "apt", "dnf"} { + if strings.Contains(row, strings.ToLower(linuxAssumption)) { + t.Errorf("unsupported host row claims Linux requirement %q: %+v", linuxAssumption, check) + } + } + wantHost := runtime.GOOS + "/" + runtime.GOARCH + if !strings.Contains(check.Detail, wantHost) { + t.Errorf("unsupported host detail = %q, want it to identify %s", check.Detail, wantHost) + } +} diff --git a/internal/hostcheck/checks_test.go b/internal/hostcheck/checks_test.go index 2e59c608..c99bbd0b 100644 --- a/internal/hostcheck/checks_test.go +++ b/internal/hostcheck/checks_test.go @@ -140,70 +140,6 @@ func TestRunChecksReportsGitAsOptionalWithDistroFix(t *testing.T) { t.Fatal("RunChecks omitted the optional git check") } -func TestKVMCheckAt(t *testing.T) { - dir := t.TempDir() - - writable := filepath.Join(dir, "kvm-ok") - if err := os.WriteFile(writable, nil, 0o666); err != nil { - t.Fatal(err) - } - if c := kvmCheckAt(writable); !c.OK { - t.Errorf("a read/write file should pass: %+v", c) - } else if len(c.Fix) != 0 { - t.Errorf("a passing check must not carry a Fix, got %v", c.Fix) - } - - missing := filepath.Join(dir, "does-not-exist") - c := kvmCheckAt(missing) - if c.OK { - t.Error("a missing device should fail") - } - if !strings.Contains(c.Detail, "not present") { - t.Errorf("Detail = %q, want it to mention the device is not present", c.Detail) - } - - denied := filepath.Join(dir, "kvm-denied") - if err := os.WriteFile(denied, nil, 0o000); err != nil { - t.Fatal(err) - } - // root ignores file permissions, so this case is only meaningful unprivileged. - if os.Geteuid() != 0 { - c := kvmCheckAt(denied) - if c.OK { - t.Error("an unreadable device should fail") - } - if c.Detail != "permission denied" { - t.Errorf("Detail = %q, want %q", c.Detail, "permission denied") - } - if len(c.Fix) == 0 { - t.Error("a permission failure must carry the usermod fix") - } - if !strings.Contains(strings.Join(c.Fix, " "), "usermod -aG kvm") { - t.Errorf("Fix = %v, want the usermod command", c.Fix) - } - } -} - -// TestKVMCheckAtOtherError drives kvmCheckAt's default branch: an error -// that is neither fs.ErrNotExist nor fs.ErrPermission. A path whose parent -// is a regular file, not a directory, reliably yields ENOTDIR. -func TestKVMCheckAtOtherError(t *testing.T) { - dir := t.TempDir() - - notADir := filepath.Join(dir, "not-a-dir") - if err := os.WriteFile(notADir, nil, 0o666); err != nil { - t.Fatal(err) - } - - c := kvmCheckAt(filepath.Join(notADir, "kvm")) - if c.OK { - t.Error("a path through a non-directory should fail") - } - if c.Detail == "" || strings.Contains(c.Detail, "not present") || c.Detail == "permission denied" { - t.Errorf("Detail = %q, want the underlying error text, not the not-exist/permission-denied cases", c.Detail) - } -} - func TestProblems(t *testing.T) { cs := []Check{ {Name: "a", OK: true}, diff --git a/internal/hostcheck/kvm_linux_test.go b/internal/hostcheck/kvm_linux_test.go new file mode 100644 index 00000000..b5e8dd62 --- /dev/null +++ b/internal/hostcheck/kvm_linux_test.go @@ -0,0 +1,74 @@ +//go:build linux + +package hostcheck + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestKVMCheckAt(t *testing.T) { + dir := t.TempDir() + + writable := filepath.Join(dir, "kvm-ok") + if err := os.WriteFile(writable, nil, 0o666); err != nil { + t.Fatal(err) + } + if c := kvmCheckAt(writable); !c.OK { + t.Errorf("a read/write file should pass: %+v", c) + } else if len(c.Fix) != 0 { + t.Errorf("a passing check must not carry a Fix, got %v", c.Fix) + } + + missing := filepath.Join(dir, "does-not-exist") + c := kvmCheckAt(missing) + if c.OK { + t.Error("a missing device should fail") + } + if !strings.Contains(c.Detail, "not present") { + t.Errorf("Detail = %q, want it to mention the device is not present", c.Detail) + } + + denied := filepath.Join(dir, "kvm-denied") + if err := os.WriteFile(denied, nil, 0o000); err != nil { + t.Fatal(err) + } + // root ignores file permissions, so this case is only meaningful unprivileged. + if os.Geteuid() != 0 { + c := kvmCheckAt(denied) + if c.OK { + t.Error("an unreadable device should fail") + } + if c.Detail != "permission denied" { + t.Errorf("Detail = %q, want %q", c.Detail, "permission denied") + } + if len(c.Fix) == 0 { + t.Error("a permission failure must carry the usermod fix") + } + if !strings.Contains(strings.Join(c.Fix, " "), "usermod -aG kvm") { + t.Errorf("Fix = %v, want the usermod command", c.Fix) + } + } +} + +// TestKVMCheckAtOtherError drives kvmCheckAt's default branch: an error +// that is neither fs.ErrNotExist nor fs.ErrPermission. A path whose parent +// is a regular file, not a directory, reliably yields ENOTDIR. +func TestKVMCheckAtOtherError(t *testing.T) { + dir := t.TempDir() + + notADir := filepath.Join(dir, "not-a-dir") + if err := os.WriteFile(notADir, nil, 0o666); err != nil { + t.Fatal(err) + } + + c := kvmCheckAt(filepath.Join(notADir, "kvm")) + if c.OK { + t.Error("a path through a non-directory should fail") + } + if c.Detail == "" || strings.Contains(c.Detail, "not present") || c.Detail == "permission denied" { + t.Errorf("Detail = %q, want the underlying error text, not the not-exist/permission-denied cases", c.Detail) + } +} diff --git a/internal/hostops/support_test.go b/internal/hostops/support_test.go new file mode 100644 index 00000000..62df09c5 --- /dev/null +++ b/internal/hostops/support_test.go @@ -0,0 +1,24 @@ +//go:build !linux + +package hostops + +import ( + "errors" + "runtime" + "strings" + "testing" +) + +func TestRequireVMUnsupportedHost(t *testing.T) { + err := RequireVM() + if err == nil { + t.Fatal("RequireVM() = nil on an unqualified native host") + } + if !errors.Is(err, ErrUnsupported) { + t.Fatalf("RequireVM() = %v, want errors.Is(..., ErrUnsupported)", err) + } + wantHost := runtime.GOOS + "/" + runtime.GOARCH + if !strings.Contains(err.Error(), wantHost) { + t.Errorf("RequireVM() = %q, want it to identify %s", err, wantHost) + } +} diff --git a/internal/qemu/run_other_test.go b/internal/qemu/run_other_test.go new file mode 100644 index 00000000..cc8a1d01 --- /dev/null +++ b/internal/qemu/run_other_test.go @@ -0,0 +1,52 @@ +//go:build !linux + +package qemu + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/hostops" +) + +func TestUnsupportedQEMUOperationsNoMutation(t *testing.T) { + root := filepath.Join(t.TempDir(), "stoat") + t.Setenv("STOAT_HOME", root) + vmDir := filepath.Join(root, "work") + if err := os.MkdirAll(vmDir, 0o755); err != nil { + t.Fatal(err) + } + v := &config.VM{Name: "work", Mode: "cloud", Backend: "cloudinit", Dir: vmDir} + if err := os.WriteFile(v.PidPath(), []byte("999999"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(v.MonitorPath(), []byte("pre-existing monitor marker"), 0o644); err != nil { + t.Fatal(err) + } + + for _, operation := range []struct { + name string + call func() error + }{ + {name: "Start", call: func() error { return Start(v) }}, + {name: "Stop", call: func() error { return Stop(v) }}, + } { + t.Run(operation.name, func(t *testing.T) { + err := operation.call() + if !errors.Is(err, hostops.ErrUnsupported) { + t.Fatalf("qemu.%s() = %v, want ErrUnsupported", operation.name, err) + } + for _, path := range []string{v.PidPath(), v.MonitorPath()} { + if _, err := os.Stat(path); err != nil { + t.Errorf("qemu.%s() changed %q before refusing: %v", operation.name, path, err) + } + } + if _, err := os.Stat(v.WorkDir()); !os.IsNotExist(err) { + t.Errorf("qemu.%s() created the backend/share artifact %q; stat err = %v", operation.name, v.WorkDir(), err) + } + }) + } +} diff --git a/internal/tui/host_other_test.go b/internal/tui/host_other_test.go new file mode 100644 index 00000000..ca493d55 --- /dev/null +++ b/internal/tui/host_other_test.go @@ -0,0 +1,25 @@ +//go:build !linux + +package tui + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/novusedge/stoat/internal/hostops" +) + +func TestUnsupportedBareTUIStartupNoMutation(t *testing.T) { + root := filepath.Join(t.TempDir(), "stoat") + t.Setenv("STOAT_HOME", root) + + err := Run() + if !errors.Is(err, hostops.ErrUnsupported) { + t.Fatalf("Run() = %v, want ErrUnsupported before TUI setup", err) + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("Run() created STOAT_HOME %q before refusing; stat err = %v", root, err) + } +} From 5914b75f4509d4cc64dad497668e0e781eee0dfd Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 06:29:02 +0300 Subject: [PATCH 2/7] fix(host): guard unqualified native builds Signed-off-by: NovusEdge --- .github/workflows/ci.yml | 6 +++ cmd/installer/main.go | 8 +--- cmd/installer/main_other.go | 9 ++-- internal/cli/cli.go | 31 +++++++++----- internal/cli/run_state.go | 6 +-- internal/config/config.go | 10 +++++ internal/core/doctor.go | 7 ++-- internal/hostcheck/checks.go | 14 ------- internal/hostcheck/checks_linux.go | 16 +++++++ internal/hostcheck/checks_other.go | 15 +++++++ internal/hostcheck/kvm_linux.go | 4 +- internal/hostops/support.go | 8 ++++ internal/hostops/support_linux.go | 7 ++++ internal/hostops/support_other.go | 15 +++++++ internal/qemu/process_common.go | 13 ++++++ internal/qemu/process_linux.go | 60 ++++++++++++++++++++++++++ internal/qemu/process_other.go | 21 ++++++++++ internal/qemu/run.go | 67 ++++-------------------------- internal/qemu/xattr.go | 7 +--- internal/qemu/xattr_common.go | 8 ++++ internal/qemu/xattr_windows.go | 9 ++++ internal/tui/app.go | 2 +- 22 files changed, 234 insertions(+), 109 deletions(-) create mode 100644 internal/hostcheck/checks_linux.go create mode 100644 internal/hostcheck/checks_other.go create mode 100644 internal/hostops/support.go create mode 100644 internal/hostops/support_linux.go create mode 100644 internal/hostops/support_other.go create mode 100644 internal/qemu/process_common.go create mode 100644 internal/qemu/process_linux.go create mode 100644 internal/qemu/process_other.go create mode 100644 internal/qemu/xattr_common.go create mode 100644 internal/qemu/xattr_windows.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42194727..d3491ab8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,5 +70,11 @@ jobs: - uses: actions/setup-go@v7 with: go-version: '1.26' + - name: build CLI + run: go build ./cmd/stoat - name: file locks run: go test ./internal/filelock + - name: host guards + run: go test ./internal/hostops ./internal/hostcheck + - name: refusal boundaries + run: go test ./internal/config ./internal/qemu ./internal/cli -run 'Host|Unsupported|NoMutation' diff --git a/cmd/installer/main.go b/cmd/installer/main.go index 1df92b07..d124d214 100644 --- a/cmd/installer/main.go +++ b/cmd/installer/main.go @@ -7,12 +7,8 @@ package main import "os" // main dispatches to run, defined per platform: main_linux.go builds and -// installs stoat, main_other.go prints the Linux-only message. -// -// A runtime.GOOS check here cannot replace this split. internal/installer -// imports kvm_linux.go's KVMCheck unconditionally, so this package refuses -// to compile on a non-Linux GOOS. Code that never compiles never runs its -// check. See kvm_linux.go's comment for the same platform-seam pattern. +// installs stoat, while main_other.go reports that source installation stays +// Linux-only. func main() { os.Exit(run()) } diff --git a/cmd/installer/main_other.go b/cmd/installer/main_other.go index 054eb551..750d905f 100644 --- a/cmd/installer/main_other.go +++ b/cmd/installer/main_other.go @@ -8,11 +8,10 @@ import ( "runtime" ) -// This build skips internal/installer entirely. That package imports -// kvm_linux.go's KVMCheck unconditionally, so it refuses to compile on this -// GOOS. Without this file, a non-Linux user would see a bare -// "undefined: KVMCheck" compiler error instead of this message. +// The source installer remains Linux-only. The command binary itself builds +// on other hosts so users can run read-only diagnostics, while native VM +// operations remain unqualified there. func run() int { - fmt.Fprintln(os.Stderr, "stoat is Linux-only: it needs KVM, and it does not compile for "+runtime.GOOS+" yet.") + fmt.Fprintln(os.Stderr, "stoat command is buildable for diagnostics on "+runtime.GOOS+"/"+runtime.GOARCH+", but native VM operations are not qualified there; source installation remains Linux-only.") return 1 } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4cd40982..dedbee84 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -28,6 +28,7 @@ import ( "github.com/novusedge/stoat/internal/config" "github.com/novusedge/stoat/internal/core" "github.com/novusedge/stoat/internal/guest" + "github.com/novusedge/stoat/internal/hostops" "github.com/novusedge/stoat/internal/keys" "github.com/novusedge/stoat/internal/logx" "github.com/novusedge/stoat/internal/mcpsrv" @@ -434,13 +435,6 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ } return runCapabilities(a, version, stdout, stderr) } - if len(a.Params) > 0 { - resolved, err := resolveParamEdits(a.Params, stdin, stderr, !jsonMode && streamIsTTY(stdin)) - if err != nil { - return a.failMsg(stdout, stderr, core.ErrInvalidSpec, err.Error()) - } - a.Params = resolved - } switch a.Cmd { case "help": @@ -455,6 +449,25 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ } fmt.Fprintln(stdout, "stoat", version) return ExitOK + case "doctor": + // Doctor is read-only by design: it may report an unqualified native + // host before root setup or parameter resolution. + return runDoctor(a, stdout, stderr) + } + + // Every mutating or process-facing command must reject before resolving + // secrets, reading project scope, creating the data root, or initializing + // logs. The independent capabilities command is dispatched before this + // boundary by its owner and remains metadata-only. + if err := hostops.RequireVM(); err != nil { + return a.fail(stdout, stderr, err) + } + if len(a.Params) > 0 { + resolved, err := resolveParamEdits(a.Params, stdin, stderr, !jsonMode && streamIsTTY(stdin)) + if err != nil { + return a.failMsg(stdout, stderr, core.ErrInvalidSpec, err.Error()) + } + a.Params = resolved } // Every other subcommand touches the data root, so it must be @@ -549,10 +562,6 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ return runCheckRecipes(a, stdout, stderr) case "update": return runUpdate(a, stdout, stderr) - case "doctor": - return runDoctor(a, stdout, stderr) - case "capabilities": - return runCapabilities(a, version, stdout, stderr) case "mcp": return runMCP(a, version, stdout, stderr) case "status": diff --git a/internal/cli/run_state.go b/internal/cli/run_state.go index 6df80422..43ee3e5d 100644 --- a/internal/cli/run_state.go +++ b/internal/cli/run_state.go @@ -176,9 +176,9 @@ func runSnapshot(a *Args, stdout, stderr io.Writer) int { return ExitOK } -// runDoctor prints core.Doctor's findings: the same checks the installer's -// pre-install checklist runs (qemu-system-x86_64, qemu-img, ssh, xorriso, -// /dev/kvm), so `stoat doctor` and `just setup` agree on host readiness. +// runDoctor prints core.Doctor's platform-specific findings. On Linux, +// `stoat doctor` and `just setup` agree on host readiness where the source +// installer is available. // // It prints every failed check's fix command, including optional dependencies. // Optional checks are warnings only; required failures remain FAIL and make diff --git a/internal/config/config.go b/internal/config/config.go index 764b38f0..8fe1f9bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/novusedge/stoat/internal/hostops" "github.com/novusedge/stoat/internal/tomlx" ) @@ -195,6 +196,9 @@ func Root() string { // EnsureRoot creates the data root and its fixed subdirectories. func EnsureRoot() error { + if err := hostops.RequireVM(); err != nil { + return err + } for _, d := range []string{"isos", "recipes"} { if err := os.MkdirAll(filepath.Join(Root(), d), 0o755); err != nil { return err @@ -266,6 +270,9 @@ func (v *VM) ISOPath() string { // Save writes vm.toml, creating the VM directory if needed. func (v *VM) Save() error { + if err := hostops.RequireVM(); err != nil { + return err + } if v.Dir == "" { v.Dir = filepath.Join(Root(), v.Name) } @@ -368,6 +375,9 @@ var sshPortLine = regexp.MustCompile(`(?m)^\s*sshport\s*=\s*(\d+)\s*$`) // Delete removes the VM directory. It never touches isos/. func (v *VM) Delete() error { + if err := hostops.RequireVM(); err != nil { + return err + } if v.Dir == "" || filepath.Dir(v.Dir) != Root() { return fmt.Errorf("refusing to delete %q: outside the data root", v.Dir) } diff --git a/internal/core/doctor.go b/internal/core/doctor.go index 3bb631fc..017190b1 100644 --- a/internal/core/doctor.go +++ b/internal/core/doctor.go @@ -20,9 +20,10 @@ type HostCheck struct { // nothing, so an MCP server or TUI panel can reuse it. The two earlier doctors // (installer's checklist, the CLI's `stoat doctor`) both wrote to a Writer. // -// It adapts hostcheck.RunChecks, which probes qemu-system-x86_64, qemu-img, -// ssh, xorriso and /dev/kvm. ssh-keygen gets no separate check: it ships in -// the same package as ssh on every supported distro. +// It adapts hostcheck.RunChecks, which probes Linux's qemu-system-x86_64, +// qemu-img, ssh, xorriso and /dev/kvm requirements. Other hosts receive one +// native qualification result instead of Linux dependency claims. ssh-keygen +// gets no separate check: it ships in the same package as ssh on Linux. func Doctor() []HostCheck { checks := hostcheck.RunChecks(hostcheck.DetectDistro()) out := make([]HostCheck, len(checks)) diff --git a/internal/hostcheck/checks.go b/internal/hostcheck/checks.go index 8c7d6a05..9f94793f 100644 --- a/internal/hostcheck/checks.go +++ b/internal/hostcheck/checks.go @@ -34,20 +34,6 @@ var binChecks = []struct { {"git", Pkg{Arch: "git", Debian: "git", Fedora: "git"}, true}, } -// RunChecks probes every host requirement, in the order they are displayed. -// None of these block the install: internal/qemu/run.go already gates VM start -// at runtime with the same two probes, so the installer's job is to say it -// early, not to enforce it. -func RunChecks(d Distro) []Check { - checks := make([]Check, 0, len(binChecks)+1) - for _, b := range binChecks { - c := lookPathCheck(b.name, d.InstallCmd(b.pkg)) - c.Optional = b.optional - checks = append(checks, c) - } - return append(checks, KVMCheck()) -} - func lookPathCheck(name string, fix []string) Check { path, err := exec.LookPath(name) if err != nil { diff --git a/internal/hostcheck/checks_linux.go b/internal/hostcheck/checks_linux.go new file mode 100644 index 00000000..7706e571 --- /dev/null +++ b/internal/hostcheck/checks_linux.go @@ -0,0 +1,16 @@ +//go:build linux + +package hostcheck + +// RunChecks probes every Linux host requirement, in the order they are +// displayed. None of these block the install: qemu.Start gates VM start at +// runtime, so the installer's job is to report missing requirements early. +func RunChecks(d Distro) []Check { + checks := make([]Check, 0, len(binChecks)+1) + for _, b := range binChecks { + c := lookPathCheck(b.name, d.InstallCmd(b.pkg)) + c.Optional = b.optional + checks = append(checks, c) + } + return append(checks, KVMCheck()) +} diff --git a/internal/hostcheck/checks_other.go b/internal/hostcheck/checks_other.go new file mode 100644 index 00000000..72dd5973 --- /dev/null +++ b/internal/hostcheck/checks_other.go @@ -0,0 +1,15 @@ +//go:build !linux + +package hostcheck + +import "github.com/novusedge/stoat/internal/hostops" + +// RunChecks reports the native qualification boundary without probing Linux +// binaries or /dev/kvm. Those requirements do not describe this host. +func RunChecks(_ Distro) []Check { + err := hostops.RequireVM() + return []Check{{ + Name: "native VM operations", + Detail: err.Error(), + }} +} diff --git a/internal/hostcheck/kvm_linux.go b/internal/hostcheck/kvm_linux.go index 8991db18..93a9c937 100644 --- a/internal/hostcheck/kvm_linux.go +++ b/internal/hostcheck/kvm_linux.go @@ -11,8 +11,8 @@ import ( // KVMCheck reports whether this user can open /dev/kvm read/write. That is // the failure mode that matters, not whether the file exists. // -// This file is the installer's one platform seam. Windows support needs a -// kvm_windows.go with a WHPX probe. Stoat does not compile for Windows yet. +// This file is the Linux-specific host-check seam. Other hosts report the +// native qualification boundary without claiming /dev/kvm as their runtime. func KVMCheck() Check { return kvmCheckAt("/dev/kvm") } func kvmCheckAt(path string) Check { diff --git a/internal/hostops/support.go b/internal/hostops/support.go new file mode 100644 index 00000000..7f0b4b1b --- /dev/null +++ b/internal/hostops/support.go @@ -0,0 +1,8 @@ +// Package hostops owns the narrow qualification gate for native VM work. +package hostops + +import "errors" + +// ErrUnsupported reports a native host whose VM operations have not been +// qualified yet. Read-only metadata and diagnostics remain available there. +var ErrUnsupported = errors.New("native VM operations are not qualified") diff --git a/internal/hostops/support_linux.go b/internal/hostops/support_linux.go new file mode 100644 index 00000000..3a341cd7 --- /dev/null +++ b/internal/hostops/support_linux.go @@ -0,0 +1,7 @@ +//go:build linux + +package hostops + +// RequireVM permits native VM operations on Linux, the currently qualified +// host platform. +func RequireVM() error { return nil } diff --git a/internal/hostops/support_other.go b/internal/hostops/support_other.go new file mode 100644 index 00000000..2015360b --- /dev/null +++ b/internal/hostops/support_other.go @@ -0,0 +1,15 @@ +//go:build !linux + +package hostops + +import ( + "fmt" + "runtime" +) + +// RequireVM refuses native VM operations until this host platform has a +// complete runtime qualification. The host identity makes the diagnostic +// actionable when the same binary is moved between platforms. +func RequireVM() error { + return fmt.Errorf("%w on %s/%s", ErrUnsupported, runtime.GOOS, runtime.GOARCH) +} diff --git a/internal/qemu/process_common.go b/internal/qemu/process_common.go new file mode 100644 index 00000000..35efb5c2 --- /dev/null +++ b/internal/qemu/process_common.go @@ -0,0 +1,13 @@ +package qemu + +import "bytes" + +// cmdlineMatches reports whether a /proc//cmdline blob belongs to the VM +// whose directory is dir. It anchors on dir+"/" rather than a bare substring +// match: cmdline always contains "-pidfile /qemu.pid", so the trailing +// separator is present for a genuine match, but a bare Contains would also +// match a sibling VM whose directory name has dir's as a prefix (e.g. "work" +// matching inside "work2"). +func cmdlineMatches(cmdline []byte, dir string) bool { + return bytes.Contains(cmdline, []byte(dir+"/")) +} diff --git a/internal/qemu/process_linux.go b/internal/qemu/process_linux.go new file mode 100644 index 00000000..2c68bbda --- /dev/null +++ b/internal/qemu/process_linux.go @@ -0,0 +1,60 @@ +//go:build linux + +package qemu + +import ( + "fmt" + "os" + "strconv" + "strings" + "syscall" + "time" + + "github.com/novusedge/stoat/internal/config" +) + +func pid(v *config.VM) int { + b, err := os.ReadFile(v.PidPath()) + if err != nil { + return 0 + } + p, err := strconv.Atoi(strings.TrimSpace(string(b))) + if err != nil { + return 0 + } + return p +} + +// Running reports whether this VM's QEMU process is alive. The cmdline check +// matters: pids are reused, and a stale pidfile would otherwise report a ghost. +func Running(v *config.VM) bool { + p := pid(v) + if p == 0 { + return false + } + cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", p)) + if err != nil { + _ = os.Remove(v.PidPath()) + return false + } + if !cmdlineMatches(cmdline, v.Dir) { + _ = os.Remove(v.PidPath()) + return false + } + return true +} + +// StartedAt returns when the VM's QEMU process started (the pidfile's mtime), +// or the zero time if it is stopped. +func StartedAt(v *config.VM) time.Time { + if !Running(v) { + return time.Time{} + } + fi, err := os.Stat(v.PidPath()) + if err != nil { + return time.Time{} + } + return fi.ModTime() +} + +func terminate(p int) error { return syscall.Kill(p, syscall.SIGTERM) } diff --git a/internal/qemu/process_other.go b/internal/qemu/process_other.go new file mode 100644 index 00000000..2142c560 --- /dev/null +++ b/internal/qemu/process_other.go @@ -0,0 +1,21 @@ +//go:build !linux + +package qemu + +import ( + "time" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/hostops" +) + +// pid is kept side-effect free on hosts without a qualified process adapter. +func pid(*config.VM) int { return 0 } + +// Running does not remove stale state on an unqualified host. qemu.Start and +// qemu.Stop reject before reaching this read path. +func Running(*config.VM) bool { return false } + +func StartedAt(*config.VM) time.Time { return time.Time{} } + +func terminate(int) error { return hostops.ErrUnsupported } diff --git a/internal/qemu/run.go b/internal/qemu/run.go index 9802d095..fb304f3d 100644 --- a/internal/qemu/run.go +++ b/internal/qemu/run.go @@ -5,13 +5,12 @@ import ( "fmt" "os" "os/exec" - "strconv" "strings" - "syscall" "time" "github.com/novusedge/stoat/internal/backend" "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/hostops" "github.com/novusedge/stoat/internal/logx" ) @@ -28,62 +27,6 @@ func Preflight() error { return nil } -func pid(v *config.VM) int { - b, err := os.ReadFile(v.PidPath()) - if err != nil { - return 0 - } - p, err := strconv.Atoi(strings.TrimSpace(string(b))) - if err != nil { - return 0 - } - return p -} - -// cmdlineMatches reports whether a /proc//cmdline blob belongs to the VM -// whose directory is dir. It anchors on dir+"/" rather than a bare substring -// match: cmdline always contains "-pidfile /qemu.pid", so the trailing -// separator is present for a genuine match, but a bare Contains would also -// match a sibling VM whose directory name has dir's as a prefix (e.g. "work" -// matching inside "work2"). -func cmdlineMatches(cmdline []byte, dir string) bool { - return bytes.Contains(cmdline, []byte(dir+"/")) -} - -// Running reports whether this VM's QEMU process is alive. The cmdline check -// matters: pids are reused, and a stale pidfile would otherwise report a ghost. -func Running(v *config.VM) bool { - p := pid(v) - if p == 0 { - return false - } - cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", p)) - if err != nil { - _ = os.Remove(v.PidPath()) - return false - } - if !cmdlineMatches(cmdline, v.Dir) { - _ = os.Remove(v.PidPath()) - return false - } - return true -} - -// StartedAt returns when the VM's QEMU process started (the pidfile's -// mtime), or the zero time if it is stopped. -daemonize rewrites the -// pidfile at the moment QEMU forks into the background, so its mtime is a -// start time and not a stale value from an earlier boot. -func StartedAt(v *config.VM) time.Time { - if !Running(v) { - return time.Time{} - } - fi, err := os.Stat(v.PidPath()) - if err != nil { - return time.Time{} - } - return fi.ModTime() -} - // installedBytes is how much has to be written into a disk VM's qcow2 before // stoat believes an OS landed in it. A freshly created 8G qcow2 is ~200 KB of // metadata and nothing else; the smallest real install is well past this. @@ -103,6 +46,9 @@ func diskWritten(v *config.VM) bool { // Start launches QEMU. -daemonize means it detaches itself; stoat supervises // nothing and tracks the process by pidfile. func Start(v *config.VM) error { + if err := hostops.RequireVM(); err != nil { + return err + } if Running(v) { return fmt.Errorf("%w: %s is already running", ErrAlreadyRunning, v.Name) } @@ -198,6 +144,9 @@ func consoleCredential(v *config.VM, user string) string { // back to SIGTERM. The fallback is a power cut: fine for live VMs, lossy for // disk ones, which is why it is not the first move. func Stop(v *config.VM) error { + if err := hostops.RequireVM(); err != nil { + return err + } if !Running(v) { return nil } @@ -214,7 +163,7 @@ func Stop(v *config.VM) error { } if p := pid(v); p != 0 { logx.L().Warn("graceful powerdown timed out, sending SIGTERM", "vm", v.Name, "pid", p) - _ = syscall.Kill(p, syscall.SIGTERM) + _ = terminate(p) } return nil } diff --git a/internal/qemu/xattr.go b/internal/qemu/xattr.go index 59eda65b..001e4ecc 100644 --- a/internal/qemu/xattr.go +++ b/internal/qemu/xattr.go @@ -1,3 +1,5 @@ +//go:build !windows + package qemu import ( @@ -7,11 +9,6 @@ import ( "golang.org/x/sys/unix" ) -// ErrNoXattr means a 9p export's backing directory sits on a filesystem that -// cannot store the user.* extended attributes security_model=mapped-xattr -// needs. -var ErrNoXattr = errors.New("filesystem does not support extended attributes") - // xattrOK reports whether dir's filesystem supports the user.* extended // attributes mapped-xattr stores its metadata in. // diff --git a/internal/qemu/xattr_common.go b/internal/qemu/xattr_common.go new file mode 100644 index 00000000..9a1f9d63 --- /dev/null +++ b/internal/qemu/xattr_common.go @@ -0,0 +1,8 @@ +package qemu + +import "errors" + +// ErrNoXattr means a 9p export's backing directory sits on a filesystem that +// cannot store the user.* extended attributes security_model=mapped-xattr +// needs. +var ErrNoXattr = errors.New("filesystem does not support extended attributes") diff --git a/internal/qemu/xattr_windows.go b/internal/qemu/xattr_windows.go new file mode 100644 index 00000000..4d89ce07 --- /dev/null +++ b/internal/qemu/xattr_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package qemu + +import "github.com/novusedge/stoat/internal/hostops" + +// xattrOK is unavailable until Windows share semantics are qualified. The +// start guard rejects before this probe could be reached. +func xattrOK(string) error { return hostops.ErrUnsupported } diff --git a/internal/tui/app.go b/internal/tui/app.go index d7e1d6bb..079d8f56 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -49,7 +49,7 @@ type model struct { // modal. modal *imageModal - preflight string // non-empty when qemu or /dev/kvm is unusable + preflight string // non-empty when a host requirement is unavailable width int height int // pendingDelete is the VM awaiting delete confirmation. One field covers From 967d24db66b348a49873f847dd62d884f42a0f13 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 06:33:58 +0300 Subject: [PATCH 3/7] test(hostcheck): split Linux check expectations Signed-off-by: NovusEdge --- internal/hostcheck/checks_linux_test.go | 73 +++++++++++++++++++++++ internal/hostcheck/checks_test.go | 78 ++----------------------- 2 files changed, 79 insertions(+), 72 deletions(-) create mode 100644 internal/hostcheck/checks_linux_test.go diff --git a/internal/hostcheck/checks_linux_test.go b/internal/hostcheck/checks_linux_test.go new file mode 100644 index 00000000..3256dd7e --- /dev/null +++ b/internal/hostcheck/checks_linux_test.go @@ -0,0 +1,73 @@ +//go:build linux + +package hostcheck + +import ( + "strings" + "testing" +) + +// An empty PATH guarantees exec.LookPath fails for everything, regardless of +// what happens to be installed on the machine running tests. Same trick as +// internal/cloudinit/cloudinit_test.go. +func TestRunChecksAllMissing(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + checks := RunChecks(DistroArch) + + var names []string + for _, c := range checks { + names = append(names, c.Name) + } + want := []string{"qemu-system-x86_64", "qemu-img", "ssh", "xorriso", "git", "/dev/kvm"} + if len(names) != len(want) { + t.Fatalf("got %d checks %v, want %d %v", len(names), names, len(want), want) + } + for i := range want { + if names[i] != want[i] { + t.Errorf("check %d = %q, want %q", i, names[i], want[i]) + } + } + + for _, c := range checks[:5] { + if c.OK { + t.Errorf("%s: OK with an empty PATH", c.Name) + } + if c.Detail != "not found" { + t.Errorf("%s: Detail = %q, want %q", c.Name, c.Detail, "not found") + } + if len(c.Fix) == 0 { + t.Errorf("%s: a failed check must carry a Fix", c.Name) + } + if !strings.HasPrefix(c.Fix[0], "sudo pacman") { + t.Errorf("%s: Fix = %v, want an arch command", c.Name, c.Fix) + } + if c.Name == "git" && !c.Optional { + t.Errorf("%s: Optional = false, want true", c.Name) + } + if c.Name != "git" && c.Optional { + t.Errorf("%s: Optional = true, want false", c.Name) + } + } +} + +func TestRunChecksReportsGitAsOptionalWithDistroFix(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + checks := RunChecks(DistroArch) + for _, c := range checks { + if c.Name != "git" { + continue + } + if !c.Optional { + t.Fatal("git is required by recipe commands but must be optional for host readiness") + } + if c.OK { + t.Fatal("git unexpectedly found with an empty PATH") + } + if got := strings.Join(c.Fix, " "); !strings.Contains(got, "git") || !strings.Contains(got, "pacman") { + t.Fatalf("git fix = %v, want an actionable Arch install command", c.Fix) + } + return + } + t.Fatal("RunChecks omitted the optional git check") +} diff --git a/internal/hostcheck/checks_test.go b/internal/hostcheck/checks_test.go index c99bbd0b..ab238b75 100644 --- a/internal/hostcheck/checks_test.go +++ b/internal/hostcheck/checks_test.go @@ -19,50 +19,6 @@ func TestFirstBinCheckMatchesQemuBinary(t *testing.T) { } } -// An empty PATH guarantees exec.LookPath fails for everything, regardless of -// what happens to be installed on the machine running tests. Same trick as -// internal/cloudinit/cloudinit_test.go. -func TestRunChecksAllMissing(t *testing.T) { - t.Setenv("PATH", t.TempDir()) - - checks := RunChecks(DistroArch) - - var names []string - for _, c := range checks { - names = append(names, c.Name) - } - want := []string{"qemu-system-x86_64", "qemu-img", "ssh", "xorriso", "git", "/dev/kvm"} - if len(names) != len(want) { - t.Fatalf("got %d checks %v, want %d %v", len(names), names, len(want), want) - } - for i := range want { - if names[i] != want[i] { - t.Errorf("check %d = %q, want %q", i, names[i], want[i]) - } - } - - for _, c := range checks[:5] { - if c.OK { - t.Errorf("%s: OK with an empty PATH", c.Name) - } - if c.Detail != "not found" { - t.Errorf("%s: Detail = %q, want %q", c.Name, c.Detail, "not found") - } - if len(c.Fix) == 0 { - t.Errorf("%s: a failed check must carry a Fix", c.Name) - } - if !strings.HasPrefix(c.Fix[0], "sudo pacman") { - t.Errorf("%s: Fix = %v, want an arch command", c.Name, c.Fix) - } - if c.Name == "git" && !c.Optional { - t.Errorf("%s: Optional = false, want true", c.Name) - } - if c.Name != "git" && c.Optional { - t.Errorf("%s: Optional = true, want false", c.Name) - } - } -} - // A binary that exists and is executable must be found, and a found check must // carry no Fix, since the Done screen prints every Fix it is given. func TestRunChecksFindsBinary(t *testing.T) { @@ -73,10 +29,11 @@ func TestRunChecksFindsBinary(t *testing.T) { } t.Setenv("PATH", dir) - for _, c := range RunChecks(DistroArch) { - if c.Name != "qemu-img" { + for _, b := range binChecks { + if b.name != "qemu-img" { continue } + c := lookPathCheck(b.name, DistroArch.InstallCmd(b.pkg)) if !c.OK { t.Fatalf("qemu-img not found on a PATH containing it: %+v", c) } @@ -93,14 +50,12 @@ func TestRunChecksFindsBinary(t *testing.T) { // An unknown distro still reports the problem and still names the packages // to install. It invents no command to run. The test iterates binChecks -// directly, not whatever RunChecks(DistroUnknown) returns, so a nil Fix -// fails the test instead of being silently skipped. +// directly, so a nil Fix fails the test instead of being silently skipped. func TestRunChecksUnknownDistroHasNoCommand(t *testing.T) { t.Setenv("PATH", t.TempDir()) - checks := RunChecks(DistroUnknown) - for i, b := range binChecks { - c := checks[i] + for _, b := range binChecks { + c := lookPathCheck(b.name, DistroUnknown.InstallCmd(b.pkg)) if c.OK { t.Fatalf("%s: OK with an empty PATH", c.Name) } @@ -119,27 +74,6 @@ func TestRunChecksUnknownDistroHasNoCommand(t *testing.T) { } } -func TestRunChecksReportsGitAsOptionalWithDistroFix(t *testing.T) { - t.Setenv("PATH", t.TempDir()) - checks := RunChecks(DistroArch) - for _, c := range checks { - if c.Name != "git" { - continue - } - if !c.Optional { - t.Fatal("git is required by recipe commands but must be optional for host readiness") - } - if c.OK { - t.Fatal("git unexpectedly found with an empty PATH") - } - if got := strings.Join(c.Fix, " "); !strings.Contains(got, "git") || !strings.Contains(got, "pacman") { - t.Fatalf("git fix = %v, want an actionable Arch install command", c.Fix) - } - return - } - t.Fatal("RunChecks omitted the optional git check") -} - func TestProblems(t *testing.T) { cs := []Check{ {Name: "a", OK: true}, From 4ab3f55d70a4dd55391042687f5bc37d2b4f2f93 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 06:34:15 +0300 Subject: [PATCH 4/7] ci(host): cover TUI refusal boundary Signed-off-by: NovusEdge --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3491ab8..7f2afd7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,4 +77,4 @@ jobs: - name: host guards run: go test ./internal/hostops ./internal/hostcheck - name: refusal boundaries - run: go test ./internal/config ./internal/qemu ./internal/cli -run 'Host|Unsupported|NoMutation' + run: go test ./internal/config ./internal/qemu ./internal/cli ./internal/tui -run 'Host|Unsupported|NoMutation' From 9b35057b6da252b8f1cc028e0443ee7459a98b64 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 12:58:49 +0300 Subject: [PATCH 5/7] test(ci): cover native capabilities discovery Signed-off-by: NovusEdge --- .github/workflows/ci.yml | 2 +- internal/cli/capabilities_other_test.go | 112 ++++++++++++++++++++++++ internal/cli/capabilities_test.go | 2 + 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 internal/cli/capabilities_other_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f2afd7e..3ad7ea34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,4 +77,4 @@ jobs: - name: host guards run: go test ./internal/hostops ./internal/hostcheck - name: refusal boundaries - run: go test ./internal/config ./internal/qemu ./internal/cli ./internal/tui -run 'Host|Unsupported|NoMutation' + run: go test ./internal/config ./internal/qemu ./internal/cli ./internal/tui -run 'Host|Unsupported|NoMutation|Capabilities' diff --git a/internal/cli/capabilities_other_test.go b/internal/cli/capabilities_other_test.go new file mode 100644 index 00000000..f529cb9c --- /dev/null +++ b/internal/cli/capabilities_other_test.go @@ -0,0 +1,112 @@ +//go:build !linux + +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +type nativeCapabilitiesPathState struct { + directory bool + contents []byte +} + +func snapshotNativeCapabilitiesRoot(t *testing.T, root string) map[string]nativeCapabilitiesPathState { + t.Helper() + state := make(map[string]nativeCapabilitiesPathState) + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + item := nativeCapabilitiesPathState{directory: entry.IsDir()} + if !entry.IsDir() { + item.contents, err = os.ReadFile(path) + if err != nil { + return err + } + } + state[rel] = item + return nil + }) + if err != nil { + t.Fatalf("snapshot %s: %v", root, err) + } + return state +} + +func TestCapabilitiesNativeReadsStoppedMetadataWithoutMutation(t *testing.T) { + root := cliRoot(t) + vmDir := filepath.Join(root, "stopped") + if err := os.MkdirAll(vmDir, 0o755); err != nil { + t.Fatal(err) + } + vmTOML := []byte("name = \"stopped\"\nmode = \"live\"\nos = \"alpine\"\nsshport = 2200\nagent_access = \"observe\"\n") + if err := os.WriteFile(filepath.Join(vmDir, "vm.toml"), vmTOML, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(vmDir, "qemu.pid"), []byte("999999999\n"), 0o644); err != nil { + t.Fatal(err) + } + wantState := snapshotNativeCapabilitiesRoot(t, root) + + cases := []struct { + name string + args []string + json bool + }{ + {name: "targetless human", args: []string{"capabilities"}}, + {name: "targetless JSON", args: []string{"--json", "capabilities"}, json: true}, + {name: "targeted human", args: []string{"capabilities", "stopped"}}, + {name: "targeted JSON", args: []string{"--json", "capabilities", "stopped"}, json: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var out, errOut bytes.Buffer + if code := Main(tc.args, "v-test", strings.NewReader(""), &out, &errOut); code != ExitOK { + t.Fatalf("capabilities exit = %d, want ExitOK; stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + if tc.json { + var envelope struct { + Cmd string `json:"cmd"` + OK bool `json:"ok"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &envelope); err != nil { + t.Fatalf("JSON capabilities output is not one envelope: %v; output=%q", err, out.String()) + } + if envelope.Cmd != "capabilities" || !envelope.OK { + t.Fatalf("JSON capabilities envelope = %+v, want cmd=capabilities ok=true", envelope) + } + var report struct { + Target *struct { + Name string `json:"name"` + } `json:"target"` + } + if err := json.Unmarshal(envelope.Data, &report); err != nil { + t.Fatalf("JSON capabilities data is not a report: %v; data=%s", err, envelope.Data) + } + if strings.HasPrefix(tc.name, "targeted") && (report.Target == nil || report.Target.Name != "stopped") { + t.Errorf("targeted report = %+v, want stopped target", report.Target) + } + if strings.HasPrefix(tc.name, "targetless") && report.Target != nil { + t.Errorf("targetless report target = %+v, want nil", report.Target) + } + } else if !strings.Contains(strings.ToUpper(out.String()), "NAME") { + t.Errorf("human capabilities omitted NAME header: %q", out.String()) + } + if got := snapshotNativeCapabilitiesRoot(t, root); !reflect.DeepEqual(got, wantState) { + t.Errorf("capabilities mutated stored root:\n before: %#v\n after: %#v", wantState, got) + } + }) + } +} diff --git a/internal/cli/capabilities_test.go b/internal/cli/capabilities_test.go index ea632e25..f9a6f971 100644 --- a/internal/cli/capabilities_test.go +++ b/internal/cli/capabilities_test.go @@ -1,3 +1,5 @@ +//go:build linux + package cli import ( From 90cb217dfe073910120225918c398d4f8857d6c5 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 13:10:29 +0300 Subject: [PATCH 6/7] ci: narrow native refusal test selector Signed-off-by: NovusEdge --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ad7ea34..803fad6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,4 +77,4 @@ jobs: - name: host guards run: go test ./internal/hostops ./internal/hostcheck - name: refusal boundaries - run: go test ./internal/config ./internal/qemu ./internal/cli ./internal/tui -run 'Host|Unsupported|NoMutation|Capabilities' + run: go test ./internal/config ./internal/qemu ./internal/cli ./internal/tui -run '^(TestUnsupported|TestHostDiagnostics|TestHostConfig|TestCapabilitiesNative)' From 61161f339d8736c246e09235d360a57cf22c015e Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 13:13:44 +0300 Subject: [PATCH 7/7] test(hostcheck): make binary fixture Windows portable Signed-off-by: NovusEdge --- internal/hostcheck/checks_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/hostcheck/checks_test.go b/internal/hostcheck/checks_test.go index ab238b75..ef084318 100644 --- a/internal/hostcheck/checks_test.go +++ b/internal/hostcheck/checks_test.go @@ -3,6 +3,7 @@ package hostcheck import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -23,7 +24,11 @@ func TestFirstBinCheckMatchesQemuBinary(t *testing.T) { // carry no Fix, since the Done screen prints every Fix it is given. func TestRunChecksFindsBinary(t *testing.T) { dir := t.TempDir() - stub := filepath.Join(dir, "qemu-img") + stubName := "qemu-img" + if runtime.GOOS == "windows" { + stubName += ".exe" + } + stub := filepath.Join(dir, stubName) if err := os.WriteFile(stub, []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) }