package host import ( "bytes" "context" "encoding/json" "errors" "fmt" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostguard" ) func TestPlan9JournalRootReadAndReceiptReplay(t *testing.T) { j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "root.db")) if err != nil { t.Fatal(err) } defer j.Close() s, err := hostbox.NewJournalService(j) if err != nil { t.Fatal(err) } in := hostbox.LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller"}, ControllerPID: 17, ProcessStart: "23", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}} q := hostbox.JournalRequest{V: 1, Op: "create", Launch: in} q = plan9CanonicalJournalRequest(t, q) if _, err = s.Execute(t.Context(), "box/enroll", q); err != nil { t.Fatal(err) } root := ClaimRootHTTP{Journals: s, RequireNewWork: func(context.Context) error { return errors.New("new work blocked") }} root.Reconcile.Box = "box" root.Reconcile.Enrollment = "enroll" peer := StopPeer{UID: 1001, PID: 17, ProcessStart: "23", BootID: "boot", ControllerID: "controller"} // The receipt predates a subsequent claim change: the replay must still // return the recorded result, without new claim authority or effects. body, _ := json.Marshal(q) response := httptest.NewRecorder() root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer) if response.Code != http.StatusOK { t.Fatal(response.Code, response.Body.String()) } q = hostbox.JournalRequest{V: 1, Op: "read", ID: "ffeeddccbbaa99887766554433221100", Target: in.Target} body, _ = json.Marshal(q) response = httptest.NewRecorder() root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer) var out hostbox.JournalResponse if err = json.Unmarshal(response.Body.Bytes(), &out); err != nil || out.Error != "" || out.Launch.Target.Host != in.Target.Host { t.Fatalf("read: %+v %v", out, err) } // Positive settlement is allowed after claim authority ends, but cannot // invent a producer. No claim store is configured here deliberately. if err = j.RecordProducer(t.Context(), in.Target, "issued"); err != nil { t.Fatal(err) } root.Reconcile.Box = "box" root.Reconcile.Enrollment = "enroll" q = hostbox.JournalRequest{V: 1, Op: "complete", Target: in.Target, Producer: "issued"} q = plan9CanonicalJournalRequest(t, q) body, _ = json.Marshal(q) response = httptest.NewRecorder() root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer) if err = json.Unmarshal(response.Body.Bytes(), &out); err != nil || out.Error != "" || out.Completion.Count != 1 { t.Fatalf("late completion: %+v %v", out, err) } root.Reconcile.Box = "" root.Reconcile.Enrollment = "" root.Reconcile.Box = "box" root.Reconcile.Enrollment = "enroll" next := in next.Target.Session++ newRequest := plan9CanonicalJournalRequest(t, hostbox.JournalRequest{Op: "create", Launch: next}) body, _ = json.Marshal(newRequest) response = httptest.NewRecorder() root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer) if response.Code != http.StatusForbidden { t.Fatalf("new-work gate bypassed: %d", response.Code) } // Root-only recovery settlement has no remotely callable operation. q.Op = "settle-recovered" q.ID = "11112222333344445555666677778888" body, _ = json.Marshal(q) response = httptest.NewRecorder() root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer) if response.Code == http.StatusOK { t.Fatal("recovery authority exposed") } response = httptest.NewRecorder() root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", strings.NewReader(`{"V":1,"Path":"/etc/passwd"}`)), peer) if response.Code != http.StatusBadRequest { t.Fatal("unknown path field accepted") } } func TestPlan9JournalNamespaceUnitIsFlagScoped(t *testing.T) { raw, err := unitsFS.ReadFile("units/" + claimControlUnit) if err != nil { t.Fatal(err) } if got := claimUnitContent(claimControlUnit, raw, claimUnitSettings{}); !bytes.Equal(got, raw) { t.Fatal("flag-off unit changed") } got := string(claimUnitContent(claimControlUnit, raw, claimUnitSettings{enabled: true, data: "/data", opt: "/opt/carlos"})) for _, want := range []string{"StateDirectory=carlos-claims", "StateDirectoryMode=0700", "/var/lib/carlos-claims", "/run/systemd/system"} { if !strings.Contains(got, want) { t.Fatalf("missing %s", want) } } } func plan9CanonicalJournalRequest(t *testing.T, q hostbox.JournalRequest) hostbox.JournalRequest { t.Helper() var captured hostbox.JournalRequest client, _ := hostbox.RemoteJournals(func(_ context.Context, in hostbox.JournalRequest) (hostbox.JournalResponse, error) { captured = in return hostbox.JournalResponse{V: 1, ID: in.ID}, nil }) var err error switch q.Op { case "create": err = client.Create(t.Context(), q.Launch) case "complete": _, err = client.CompleteProducer(t.Context(), q.Target, q.Producer, q.Previous) default: t.Fatal("unsupported test request") } if err != nil { t.Fatal(err) } return captured } func TestPlan9JournalTransportDefiniteRefusalAndLostReply(t *testing.T) { for _, status := range []int{http.StatusBadRequest, http.StatusForbidden, http.StatusConflict} { t.Run(fmt.Sprint(status), func(t *testing.T) { var calls atomic.Int32 call := plan9JournalSocket(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1); w.WriteHeader(status) })) client, _ := hostbox.RemoteJournals(call) err := client.Create(t.Context(), hostbox.LaunchInventory{}) var uncertain *hostbox.JournalUncertainError if err == nil || errors.As(err, &uncertain) { t.Fatalf("definite refusal became uncertain: %v", err) } if calls.Load() != 1 { t.Fatalf("definite refusal retried %d times", calls.Load()) } }) } var calls atomic.Int32 call := plan9JournalSocket(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if calls.Add(1) == 1 { conn, _, err := w.(http.Hijacker).Hijack() if err == nil { conn.Close() } return } w.WriteHeader(http.StatusConflict) })) client, _ := hostbox.RemoteJournals(call) err := client.Create(t.Context(), hostbox.LaunchInventory{}) var uncertain *hostbox.JournalUncertainError if !errors.As(err, &uncertain) || calls.Load() != 2 { t.Fatalf("lost first response was incorrectly settled: %v calls=%d", err, calls.Load()) } } func plan9JournalSocket(t *testing.T, handler http.Handler) hostbox.JournalCall { t.Helper() dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "journal-sock-") if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(dir) }) path := filepath.Join(dir, "root.sock") listener, err := net.Listen("unix", path) if err != nil { t.Fatal(err) } server := &http.Server{Handler: handler} done := make(chan struct{}) go func() { defer close(done); _ = server.Serve(listener) }() t.Cleanup(func() { server.Close(); <-done }) return rootJournalClient(path) }