| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "crypto/sha256" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/hostactors" |
| 14 | "github.com/carlosframework/platform/internal/hostclaim" |
| 15 | "github.com/carlosframework/platform/internal/hostevidence" |
| 16 | "github.com/carlosframework/platform/internal/hostguard" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | func TestPlan11ReleaseNeedsExactSignedRootStop(t *testing.T) { |
| 21 | t.Parallel() |
| 22 | for _, bad := range []string{"", "unsigned", "wrong-stamp", "retirement", "wrong-enrollment", "wrong-session", "session-zero", "zero-stale", "zero-as-stopped", "positive-as-no-process"} { |
| 23 | t.Run(bad, func(t *testing.T) { |
| 24 | ctx := context.Background() |
| 25 | s := release.NewDirStore(t.TempDir()) |
| 26 | root := ed25519.NewKeyFromSeed(make([]byte, 32)) |
| 27 | boxKey := ed25519.NewKeyFromSeed([]byte(strings.Repeat("b", 32))) |
| 28 | d := hostactors.Directory{Store: s, PublicKey: root.Public().(ed25519.PublicKey), Bucket: "bucket"} |
| 29 | en := strings.Repeat("a", 32) |
| 30 | roster := hostactors.Roster{V: 1, Revision: 1, DeploymentBucket: "bucket", Boxes: map[string]hostactors.Box{"box": {Enrollment: en, BoxID: "box", Label: "box", Namespace: "platform", Region: "ie", Pool: "default", State: "active", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("d", 64), CensusPublicKey: boxKey.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| 31 | wire, _ := release.SignRootEnvelope(release.RootSigner(root), roster) |
| 32 | if _, err := s.Put(ctx, hostactors.Key, wire, ""); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | host := "shop.example" |
| 36 | created := time.Date(2026, 10, 9, 0, 0, 0, 0, time.UTC) |
| 37 | inc, _ := hostclaim.Incarnation("bab", "jam", host, created) |
| 38 | claim := hostclaim.Claim{V: 2, Gen: 1, Host: host, Holder: &hostclaim.Holder{Family: "instance", Key: "apps/bab/jam/instances/" + host + ".json", Account: "bab", App: "jam", Incarnation: inc, CreatedAt: created, Location: "instances/" + host + "/" + inc, Stamp: 7, Session: 7, WriterBox: "box"}, Op: hostclaim.Op{ID: "reloc", Kind: hostclaim.KindRelocating, Phase: "releasing", Exec: "box", Intent: map[string]string{"source_box": "box", "source_enrollment": en, "region": "ie"}}, Obligations: []hostclaim.Obligation{{ID: "relocation-release", Kind: "relocation-release", Op: "reloc", Box: "box", Region: "ie", Owner: "region:ie", Retirement: "install-7"}}} |
| 39 | if bad == "session-zero" || strings.HasPrefix(bad, "zero-") { |
| 40 | claim.Holder.Session = 0 |
| 41 | claim.Holder.WriterBox = "" |
| 42 | } |
| 43 | raw, _ := json.Marshal(claim) |
| 44 | key, _ := hostclaim.Key(host) |
| 45 | tag, err := s.Put(ctx, key, raw, "") |
| 46 | if err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | obs := hostclaim.Observed{Host: host, Claim: &claim, ETag: tag} |
| 50 | if err := hostclaim.CompleteObligation("reloc", "relocation-release")(&claim); err == nil { |
| 51 | t.Fatal("generic chore completion discharged release") |
| 52 | } |
| 53 | receipt := StopReceipt{V: 1, ID: "stop-1", Kind: "stopped", Host: host, Incarnation: inc, InstallationStamp: 7, WriterSession: 7, OperationID: "reloc", Phase: "releasing", ObligationID: "relocation-release", Box: "box", Enrollment: en, Region: "ie", EvidenceAt: time.Now(), RequestLimitNS: int64(120 * time.Second), ClaimGen: 1, FenceID: "fence", InventorySHA256: strings.Repeat("e", 64), LocalJournalSHA256: strings.Repeat("f", 64), ProducerDrainDigest: strings.Repeat("a", 64), CgroupIdentities: []string{"root-role"}} |
| 54 | if claim.Holder.Session == 0 || bad == "positive-as-no-process" { |
| 55 | receipt.WriterSession = claim.Holder.Session |
| 56 | receipt.Kind = StopReceiptNoProcess |
| 57 | receipt.FenceID = "" |
| 58 | receipt.ProducerDrainDigest = "" |
| 59 | receipt.ClaimETag = tag |
| 60 | } |
| 61 | switch bad { |
| 62 | case "zero-stale": |
| 63 | receipt.ClaimETag = "old-etag" |
| 64 | case "zero-as-stopped": |
| 65 | receipt.Kind = "stopped" |
| 66 | case "wrong-stamp": |
| 67 | receipt.InstallationStamp++ |
| 68 | case "retirement": |
| 69 | receipt.ObligationID = "park-local" |
| 70 | case "wrong-enrollment": |
| 71 | receipt.Enrollment = strings.Repeat("f", 32) |
| 72 | case "wrong-session": |
| 73 | receipt.WriterSession++ |
| 74 | } |
| 75 | body, _ := json.Marshal(receipt) |
| 76 | receiptKey := stopEvidenceKey(host, "reloc", "box", "stop-1") |
| 77 | sum := sha256.Sum256(body) |
| 78 | if bad == "unsigned" { |
| 79 | _, err = s.Put(ctx, receiptKey, body, "") |
| 80 | } else { |
| 81 | err = hostevidence.Publish(ctx, s, boxKey, receiptKey, body) |
| 82 | } |
| 83 | if err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | got, err := (RelocationReleaseReceipts{Store: s, Directory: d}).Complete(ctx, obs, receiptKey, hex.EncodeToString(sum[:])) |
| 87 | if bad != "" && bad != "session-zero" { |
| 88 | if err == nil { |
| 89 | t.Fatal("invalid release receipt accepted") |
| 90 | } |
| 91 | return |
| 92 | } |
| 93 | if err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | if len(got.Claim.Obligations) != 0 || got.Claim.Holder.Stamp != 7 || got.Claim.Op.Phase != "releasing" { |
| 97 | t.Fatal("release completion changed authority") |
| 98 | } |
| 99 | replayed, err := (RelocationReleaseReceipts{Store: s, Directory: d}).Complete(ctx, got, receiptKey, hex.EncodeToString(sum[:])) |
| 100 | if err != nil || replayed.ETag != got.ETag { |
| 101 | t.Fatal("release replay changed claim", err) |
| 102 | } |
| 103 | |
| 104 | }) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestPlan11RootReleaseRequiresItsStopOnlyObligation(t *testing.T) { |
| 109 | c, req, _ := claimStopFixture(t) |
| 110 | o := c.Claims.(stopClaimReader).obs |
| 111 | o.Claim.Op.Kind = hostclaim.KindRelocating |
| 112 | o.Claim.Op.Phase = "releasing" |
| 113 | o.Claim.Op.Intent = map[string]string{"region": "ie", "source_box": "box-1"} |
| 114 | req.Phase = "releasing" |
| 115 | req.Region = "ie" |
| 116 | req.ObligationID = "relocation-release" |
| 117 | target := hostguard.FenceTarget{Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp, Session: req.WriterSession, Op: req.OperationID, Phase: req.Phase, Box: req.Box, Region: req.Region, ObligationID: req.ObligationID} |
| 118 | if hostguard.StopAuthorityMatches(o.Claim, target) { |
| 119 | t.Fatal("release accepted without obligation") |
| 120 | } |
| 121 | o.Claim.Obligations = []hostclaim.Obligation{{ID: "relocation-release", Kind: "relocation-release", Op: req.OperationID, Region: "ie", Box: req.Box, Owner: "region:ie", Retirement: "install"}} |
| 122 | if !hostguard.StopAuthorityMatches(o.Claim, target) { |
| 123 | t.Fatal("bound release refused") |
| 124 | } |
| 125 | o.Claim.Obligations[0].Park = &hostclaim.ParkScope{} |
| 126 | if hostguard.StopAuthorityMatches(o.Claim, target) { |
| 127 | t.Fatal("release with park scope accepted") |
| 128 | } |
| 129 | } |
| 130 | |