diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 2dddcb2..4ffd649 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -15,6 +15,11 @@ services: GOFLAGS: -p=2 HOME: /tmp XDG_CACHE_HOME: /cache/xdg + tmpfs: + # Integration binaries must exercise the production /run Unix-socket + # policy without writing host runtime state as the unprivileged toolchain + # user. + - /run:uid=1001,gid=1001,mode=0700,size=8m volumes: - ..:/workspace - rvbox-go-build:/cache/go-build diff --git a/test/coverage.toml b/test/coverage.toml index ca11153..d98475c 100644 --- a/test/coverage.toml +++ b/test/coverage.toml @@ -45,7 +45,10 @@ tests = ["internal/server/control/service_test.go:TestCommandPaginationBindsFilt id = "HP-CTL-08" layer = "integration" status = "implemented" -tests = ["internal/server/control/service_test.go:TestControlGRPCRoundTrip_HP_CONTROL_06"] +tests = [ + "internal/server/control/service_test.go:TestControlGRPCRoundTrip_HP_CONTROL_06", + "test/integration/controlplane/controlplane_integration_test.go:TestCompiledServerAndRVC_HP_CTL_08", +] [[requirements]] id = "HP-CTL-09" diff --git a/test/harness/harness.go b/test/harness/harness.go index 9fc0fe2..60d5a6d 100644 --- a/test/harness/harness.go +++ b/test/harness/harness.go @@ -355,7 +355,7 @@ func (h *harness) runIntegrationSuite(ctx context.Context, current *manifest, su case "server-session": return h.runServerSessionSuite(ctx, current, testCase) case "control": - return h.runGoSuite(ctx, current, "control-real-grpc", "running gRPC control and JSON-RPC compatibility cases", "./internal/server/control", testCase) + return h.runGoSuite(ctx, current, "control-compiled-server-rvc", "running compiled server/rvc control-plane boundary cases", "./test/integration/controlplane", testCase) case "client-agent": return h.runGoSuite(ctx, current, "client-agent-real-websocket", "running real client/server WebSocket control-flow cases", "./test/integration/clientagent", testCase) default: diff --git a/test/integration/controlplane/controlplane_integration_test.go b/test/integration/controlplane/controlplane_integration_test.go new file mode 100644 index 0000000..dc44059 --- /dev/null +++ b/test/integration/controlplane/controlplane_integration_test.go @@ -0,0 +1,91 @@ +package controlplane_test + +import ( + "bytes" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +// TestCompiledServerAndRVC_HP_CTL_08 proves the public Linux control boundary: +// independently compiled binaries, a mode-0600 Unix socket, and a real gRPC +// request. It deliberately does not import either command package. +func TestCompiledServerAndRVC_HP_CTL_08(t *testing.T) { + root := filepath.Clean(filepath.Join("..", "..", "..")) + work := t.TempDir() + serverBin := filepath.Join(work, "rvbox-server") + rvcBin := filepath.Join(work, "rvc") + build := func(output, target string) { + t.Helper() + command := exec.Command("go", "build", "-trimpath", "-o", output, target) + command.Dir = root + if data, err := command.CombinedOutput(); err != nil { + t.Fatalf("build %s: %v\n%s", target, err, data) + } + } + build(serverBin, "./cmd/rvbox-server") + build(rvcBin, "./cmd/rvc") + + agentAddress := reserveLoopbackAddress(t) + healthAddress := reserveLoopbackAddress(t) + runtime, err := os.MkdirTemp("/run", "rvbox-controlplane-") + if err != nil { + t.Fatalf("create runtime socket directory: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(runtime) }) + socket := filepath.Join(runtime, "control.sock") + config := filepath.Join(work, "server.toml") + contents := fmt.Sprintf("[server]\ndata_dir = %q\nagent_listen = %q\ncontrol_socket = %q\n\n[observability]\nlisten = %q\n", filepath.Join(work, "state"), agentAddress, socket, healthAddress) + if err := os.WriteFile(config, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + + var serverLog bytes.Buffer + server := exec.Command(serverBin, "--config", config) + server.Stdout = &serverLog + server.Stderr = &serverLog + if err := server.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if server.Process != nil { + _ = server.Process.Signal(os.Interrupt) + } + _ = server.Wait() + }) + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + info, err := os.Stat(socket) + if err == nil { + if info.Mode().Perm() != 0o600 { + t.Fatalf("control socket permissions = %o, want 0600", info.Mode().Perm()) + } + break + } + time.Sleep(25 * time.Millisecond) + } + if _, err := os.Stat(socket); err != nil { + t.Fatalf("server did not create control socket: %v\n%s", err, serverLog.String()) + } + command := exec.Command(rvcBin, "--socket", socket, "stat") + if data, err := command.CombinedOutput(); err != nil { + t.Fatalf("rvc stat: %v\n%s\nserver:\n%s", err, data, serverLog.String()) + } +} + +func reserveLoopbackAddress(t *testing.T) string { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + address := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatal(err) + } + return address +}