| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "reflect" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/hostbox" |
| 14 | "github.com/carlosframework/platform/internal/hostevidence" |
| 15 | "github.com/carlosframework/platform/internal/hostguard" |
| 16 | "github.com/carlosframework/platform/internal/hostops" |
| 17 | ) |
| 18 | |
| 19 | type exactEvidenceInventory struct{ StopInventoryReader } |
| 20 | |
| 21 | func (i exactEvidenceInventory) ReadExactStopInventory(ctx context.Context, target hostguard.FenceTarget) (StopInventory, error) { |
| 22 | return i.ReadStopInventory(ctx, target.Host) |
| 23 | } |
| 24 | |
| 25 | func TestClaimPendingStopRecoveryProducesPublishableSignedReceipt(t *testing.T) { |
| 26 | c, req, _ := claimStopFixture(t) |
| 27 | seq := 0 |
| 28 | c.NewID = func() string { seq++; return fmt.Sprintf("id-%d", seq) } |
| 29 | originalKernel := c.Kernel |
| 30 | interrupted := errors.New("interrupted after fence") |
| 31 | c.Kernel = stopKernelFunc(func(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) { |
| 32 | return StopKernelEvidence{}, interrupted |
| 33 | }) |
| 34 | if _, err := c.FenceAndStop(t.Context(), req); !errors.Is(err, interrupted) { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | pending, err := c.Guard.PendingFences(t.Context()) |
| 38 | if err != nil || len(pending) != 1 { |
| 39 | t.Fatalf("pending = %+v, %v", pending, err) |
| 40 | } |
| 41 | target := pending[0] |
| 42 | c.Inventory = exactEvidenceInventory{c.Inventory} |
| 43 | calls := 0 |
| 44 | c.Kernel = recoveryOnlyKernel{recover: func(ctx context.Context, target hostguard.FenceTarget, inv StopInventory) (StopKernelEvidence, DrainAcknowledgement, error) { |
| 45 | calls++ |
| 46 | ack, err := c.Drainer.DrainClaim(ctx, req, target.FenceID) |
| 47 | if err != nil { |
| 48 | return StopKernelEvidence{}, ack, err |
| 49 | } |
| 50 | evidence, err := originalKernel.VerifyStopped(ctx, target, inv, ack) |
| 51 | evidence.ObservedBootID = "recovered-boot" |
| 52 | return evidence, ack, err |
| 53 | }} |
| 54 | for _, broken := range []string{"signer", "request-limit"} { |
| 55 | without := c |
| 56 | if broken == "signer" { |
| 57 | without.EvidenceSigner = nil |
| 58 | } else { |
| 59 | without.RequestLimit = 0 |
| 60 | } |
| 61 | if _, err := without.RecoverStopped(t.Context(), target); !errors.Is(err, ErrClaimStopUnavailable) || calls != 0 { |
| 62 | t.Fatalf("recovery without %s: %v, kernel calls=%d", broken, err, calls) |
| 63 | } |
| 64 | } |
| 65 | result, err := c.RecoverStopped(t.Context(), target) |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | c.Peers = stopPeerFunc(func(context.Context) (StopPeer, error) { |
| 70 | return StopPeer{UID: 1001, PID: 43, ProcessStart: "start-43", BootID: "recovered-boot", ControllerID: "next-controller"}, nil |
| 71 | }) |
| 72 | req.Recover = true |
| 73 | replayed, err := c.FenceAndStop(t.Context(), req) |
| 74 | if err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | if !bytes.Equal(replayed.Bytes, result.Bytes) || replayed.Key != result.Key || calls != 1 { |
| 78 | t.Fatal("successor did not publish the exact recovered receipt") |
| 79 | } |
| 80 | signature, _, err := c.Evidence.Get(t.Context(), result.Key+".sig") |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if err := hostevidence.Verify(c.EvidenceSigner.Public().(ed25519.PublicKey), result.Key, result.Bytes, signature); err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | var producer StopReceipt |
| 88 | var consumer hostops.StopReceipt |
| 89 | if err := json.Unmarshal(result.Bytes, &producer); err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | // The receipt adds exactly one field the unchanged console does not |
| 93 | // declare, binding (roster binding R1; hostops' decoder tolerates it). |
| 94 | // Every other field is the console's, with the same values. |
| 95 | var fields map[string]json.RawMessage |
| 96 | if err := json.Unmarshal(result.Bytes, &fields); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | if string(fields["binding"]) != `"`+req.ExpectedDirectoryRevision+`"` || string(fields["directory_revision"]) != `"7"` { |
| 100 | t.Fatalf("recovered receipt stamps: numeral %s binding %s", fields["directory_revision"], fields["binding"]) |
| 101 | } |
| 102 | delete(fields, "binding") |
| 103 | rest, err := json.Marshal(fields) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | decoder := json.NewDecoder(bytes.NewReader(rest)) |
| 108 | decoder.DisallowUnknownFields() |
| 109 | if err := decoder.Decode(&consumer); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | if producer.RequestLimitNS != int64(hostbox.RequestLimit) || producer.ClaimETag != target.ClaimETag || producer.ObservedBootID != "recovered-boot" { |
| 113 | t.Fatalf("incomplete recovered receipt: %+v", producer) |
| 114 | } |
| 115 | consumerBytes, err := json.Marshal(consumer) |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | var got map[string]any |
| 120 | var want map[string]any |
| 121 | if json.Unmarshal(consumerBytes, &got) != nil || json.Unmarshal(rest, &want) != nil || !reflect.DeepEqual(got, want) { |
| 122 | t.Fatal("producer and consumer receipt fields differ") |
| 123 | } |
| 124 | if _, err := validateTransportStop(req, result); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | } |
| 128 | |