package host import ( "bytes" "context" "crypto/ed25519" "encoding/json" "errors" "fmt" "reflect" "testing" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostevidence" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/hostops" ) type exactEvidenceInventory struct{ StopInventoryReader } func (i exactEvidenceInventory) ReadExactStopInventory(ctx context.Context, target hostguard.FenceTarget) (StopInventory, error) { return i.ReadStopInventory(ctx, target.Host) } func TestClaimPendingStopRecoveryProducesPublishableSignedReceipt(t *testing.T) { c, req, _ := claimStopFixture(t) seq := 0 c.NewID = func() string { seq++; return fmt.Sprintf("id-%d", seq) } originalKernel := c.Kernel interrupted := errors.New("interrupted after fence") c.Kernel = stopKernelFunc(func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) { return StopKernelEvidence{}, interrupted }) if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, interrupted) { t.Fatal(err) } pending, err := c.Guard.PendingFences(t.Context()) if err != nil || len(pending) != 1 { t.Fatalf("pending = %+v, %v", pending, err) } target := pending[0] c.Inventory = exactEvidenceInventory{c.Inventory} calls := 0 c.Kernel = recoveryOnlyKernel{recover: func(ctx context.Context, target hostguard.FenceTarget, inv StopInventory) (StopKernelEvidence, DrainAcknowledgement, error) { calls++ ack, err := c.Drainer.DrainClaim(ctx, req, target.FenceID) if err != nil { return StopKernelEvidence{}, ack, err } evidence, err := originalKernel.VerifyStopped(ctx, target, inv, ack) evidence.ObservedBootID = "recovered-boot" return evidence, ack, err }} for _, broken := range []string{"signer", "request-limit"} { without := c if broken == "signer" { without.EvidenceSigner = nil } else { without.RequestLimit = 0 } if _, err := without.RecoverStopped(t.Context(), target); !errors.Is(err, ErrClaimStopUnavailable) || calls != 0 { t.Fatalf("recovery without %s: %v, kernel calls=%d", broken, err, calls) } } result, err := c.RecoverStopped(t.Context(), target) if err != nil { t.Fatal(err) } c.Peers = stopPeerFunc(func(context.Context) (StopPeer, error) { return StopPeer{UID: 1001, PID: 43, ProcessStart: "start-43", BootID: "recovered-boot", ControllerID: "next-controller"}, nil }) req.Recover = true replayed, err := c.FenceAndStop(t.Context(), req) if err != nil { t.Fatal(err) } if !bytes.Equal(replayed.Bytes, result.Bytes) || replayed.Key != result.Key || calls != 1 { t.Fatal("successor did not publish the exact recovered receipt") } signature, _, err := c.Evidence.Get(t.Context(), result.Key+".sig") if err != nil { t.Fatal(err) } if err := hostevidence.Verify(c.EvidenceSigner.Public().(ed25519.PublicKey), result.Key, result.Bytes, signature); err != nil { t.Fatal(err) } var producer StopReceipt var consumer hostops.StopReceipt if err := json.Unmarshal(result.Bytes, &producer); err != nil { t.Fatal(err) } // The receipt adds exactly one field the unchanged console does not // declare, binding (roster binding R1; hostops' decoder tolerates it). // Every other field is the console's, with the same values. var fields map[string]json.RawMessage if err := json.Unmarshal(result.Bytes, &fields); err != nil { t.Fatal(err) } if string(fields["binding"]) != `"`+req.ExpectedDirectoryRevision+`"` || string(fields["directory_revision"]) != `"7"` { t.Fatalf("recovered receipt stamps: numeral %s binding %s", fields["directory_revision"], fields["binding"]) } delete(fields, "binding") rest, err := json.Marshal(fields) if err != nil { t.Fatal(err) } decoder := json.NewDecoder(bytes.NewReader(rest)) decoder.DisallowUnknownFields() if err := decoder.Decode(&consumer); err != nil { t.Fatal(err) } if producer.RequestLimitNS != int64(hostbox.RequestLimit) || producer.ClaimETag != target.ClaimETag || producer.ObservedBootID != "recovered-boot" { t.Fatalf("incomplete recovered receipt: %+v", producer) } consumerBytes, err := json.Marshal(consumer) if err != nil { t.Fatal(err) } var got map[string]any var want map[string]any if json.Unmarshal(consumerBytes, &got) != nil || json.Unmarshal(rest, &want) != nil || !reflect.DeepEqual(got, want) { t.Fatal("producer and consumer receipt fields differ") } if _, err := validateTransportStop(req, result); err != nil { t.Fatal(err) } }