package host import ( "bytes" "context" "encoding/json" "os" "path/filepath" "testing" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootparticipation" "github.com/carlosframework/platform/internal/rootstate" ) func TestPlan14GenerationRegistryRows(t *testing.T) { for _, attack := range []string{"none", "predecessor", "unknown-route-host", "unknown-timer-host", "unknown-host-during-kernel", "route-missing", "route-incarnation", "route-stamp", "route-owner", "route-id", "timer-missing", "timer-both-omitted", "timer-incarnation", "timer-stamp", "timer-owner", "timer-id", "timer-registration", "timer-extra", "route-entry-missing", "timer-entry-missing", "duplicate-entry", "timer-other-host", "wrong-root", "empty-installation", "unreadable", "changed-during-kernel", "row-next-pass", "timer-delivery-progress", "closed-registry"} { t.Run(attack, func(t *testing.T) { f, disk, _ := generationLaunchFixture(t) if _, err := f.root.Serve(t.Context(), f.peer, f.req); err != nil { t.Fatal(err) } reservation := readGenerationLaunch(t, disk) views, err := f.root.Guard.Root.GenerationInstallations(t.Context()) if err != nil { t.Fatal(err) } launch, err := f.journal.Read(t.Context(), f.target) if err != nil { t.Fatal(err) } if attack == "predecessor" { views[0].Retired = []hostguard.Captured{{Incarnation: f.target.Incarnation, Stamp: f.target.Stamp}} views[0].Installation.Stamp++ views[0].Installation.Empty = true } views[0].Installation.Artifacts.Timers = "backup" payloads := generationPayloadFixture(views) index := 0 if attack == "predecessor" { index = 1 payloads[index].Inventory.Entries = []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: "route", ManagedRoot: "registry", RelativePath: f.target.Host, RowIdentity: "row", Ownership: "owner", Materialization: "materialized"}} } payloads[index].Inventory.Entries = append(payloads[index].Inventory.Entries, hostbox.StopInventoryEntry{Kind: "timer", ArtifactID: "timer", ManagedRoot: "registry", RelativePath: f.target.Host + "/backup", RowIdentity: "timer", Ownership: "owner", Materialization: "materialized"}) snapshot := generationInventory{Census: rootparticipation.Census{Schema: 1, Box: f.target.Box, Enrollment: f.target.Enrollment, Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true}, Installations: views, Payloads: payloads, Launches: []hostbox.LaunchInventory{launch}} registryPath := filepath.Join(t.TempDir(), "registry.db") reg, err := registry.Open(registryPath) if err != nil { t.Fatal(err) } defer reg.Close() t.Setenv("CARLOS_HOST_CLAIM", "true") owner := registry.ClaimOwnership{ClaimIncarnation: f.target.Incarnation, ClaimStamp: f.target.Stamp, ClaimOwner: "owner", ClaimRowID: "row"} route := registry.Route{Host: f.target.Host, ClaimOwnership: owner} timer := registry.Timer{Host: f.target.Host, Name: "backup", Kind: "cron", Registration: "timer", ClaimOwnership: owner} timer.ClaimRowID = "timer" put := func(rt registry.Route, timers []registry.Timer) { t.Helper() if err := reg.Delete(route.Host); err != nil { t.Fatal(err) } if err := reg.ReplaceTimers(route.Host, timers); err != nil { t.Fatal(err) } if rt.Host != "" { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } } put(route, []registry.Timer{timer}) active := false reader := reg v := generationReconciler{read: func(ctx context.Context) (generationInventory, error) { return generationRegistryInventory(ctx, reader, snapshot) }, kernel: func(context.Context) (string, string, error) { if active && attack == "unknown-host-during-kernel" { if err := reg.ReplaceTimers("omitted.example", []registry.Timer{{Host: "omitted.example", Name: "paused", Kind: "cron", NextAt: 0}}); err != nil { t.Fatal(err) } } if active && attack == "changed-during-kernel" { // The registry changes after the first read while root inventory stays fixed. next := route next.ClaimRowID = "replacement" put(next, []registry.Timer{timer}) } return "boot", reservation.Binding.Digest, nil }} source := generationPinSource{open: func() (rootstate.Store, error) { return disk, nil }, reconcile: v.reconcile} ledger := &release.RootGenerations{Store: disk} accept := func() { t.Helper() pins, err := source.StagedPins(t.Context(), f.root.generations.optDir) if err != nil { t.Fatal(err) } if pinned, err := pins.Refers(reservation.Binding.Path); err != nil || !pinned { t.Fatalf("reservation lost: %v", err) } if c, err := ledger.Completeness(); err != nil || !c.Complete { t.Fatalf("row census not complete: %+v %v", c, err) } } accept() if attack == "none" || attack == "predecessor" { return } initial, _ := ledger.Completeness() saved, err := json.Marshal(snapshot) if err != nil { t.Fatal(err) } rt, tm := route, timer switch attack { case "unknown-route-host": if err := reg.Upsert(registry.Route{Host: "omitted.example"}); err != nil { t.Fatal(err) } case "unknown-timer-host": if err := reg.ReplaceTimers("omitted.example", []registry.Timer{{Host: "omitted.example", Name: "paused", Kind: "cron", NextAt: 0}}); err != nil { t.Fatal(err) } case "route-missing": rt = registry.Route{} case "route-incarnation": rt.ClaimIncarnation = "other" case "route-stamp": rt.ClaimStamp++ case "route-owner": rt.ClaimOwner = "other" case "route-id": rt.ClaimRowID = "other" case "timer-incarnation": tm.ClaimIncarnation = "other" case "timer-stamp": tm.ClaimStamp++ case "timer-owner": tm.ClaimOwner = "other" case "timer-id": tm.ClaimRowID = "other" case "timer-registration": tm.Registration = "other" case "route-entry-missing": snapshot.Payloads[index].Inventory.Entries = snapshot.Payloads[index].Inventory.Entries[1:] case "timer-entry-missing", "timer-both-omitted": snapshot.Payloads[index].Inventory.Entries = snapshot.Payloads[index].Inventory.Entries[:1] case "duplicate-entry": snapshot.Payloads[index].Inventory.Entries = append(snapshot.Payloads[index].Inventory.Entries, snapshot.Payloads[index].Inventory.Entries[1]) case "timer-other-host": snapshot.Payloads[index].Inventory.Entries[1].RelativePath = "other.example/backup" case "wrong-root": snapshot.Payloads[index].Inventory.Entries[1].ManagedRoot = "data" case "empty-installation": snapshot.Installations[0].Installation.Empty = true snapshot.Payloads[0].Inventory.Entries = nil snapshot.Launches = nil case "unreadable": reader = nil case "row-next-pass": tm.Registration, tm.ClaimRowID = "replacement", "replacement" snapshot.Payloads[index].Inventory.Entries[1].RowIdentity = "replacement" case "timer-delivery-progress": tm.NextAt++ tm.Attempts++ tm.LastStatus = "running" } timers := []registry.Timer{tm} if attack == "timer-missing" || attack == "timer-both-omitted" { timers = nil } if attack == "timer-extra" { extra := timer extra.Name, extra.Registration, extra.ClaimRowID = "extra", "extra", "extra" timers = append(timers, extra) } put(rt, timers) if attack == "closed-registry" { if err := reg.Close(); err != nil { t.Fatal(err) } } if attack == "row-next-pass" || attack == "timer-delivery-progress" { accept() next, _ := ledger.Completeness() changed := next.Evidence.Installations != initial.Evidence.Installations if changed != (attack == "row-next-pass") { t.Fatalf("row identity digest changed=%v for %s", changed, attack) } return } active = true if pins, err := source.StagedPins(t.Context(), f.root.generations.optDir); err == nil || pins != nil { t.Fatalf("registry mismatch certified: %v %v", pins, err) } if c, err := ledger.Completeness(); err != nil || c.Complete || c.Revision <= initial.Revision { t.Fatalf("invalid rows retained certificate: %+v %v", c, err) } if got, err := ledger.Read(reservation.Binding); err != nil || got.Binding != reservation.Binding || got.Closed { t.Fatal("row refusal changed reservation") } if attack == "closed-registry" { reg, err = registry.Open(registryPath) if err != nil { t.Fatal(err) } defer reg.Close() } active, reader = false, reg if err := reg.Delete("omitted.example"); err != nil { t.Fatal(err) } if err := reg.ReplaceTimers("omitted.example", nil); err != nil { t.Fatal(err) } if err := json.Unmarshal(saved, &snapshot); err != nil { t.Fatal(err) } put(route, []registry.Timer{timer}) accept() }) } } func TestPlan14GenerationRegistryReadOnly(t *testing.T) { t.Setenv("CARLOS_DATA_DIR", t.TempDir()) if _, err := productionGenerationRegistryInventory(t.Context(), generationInventory{}); err == nil { t.Fatalf("missing registry: %v", err) } if _, err := os.Stat(filepath.Join(dataDir(), "registry.db")); !os.IsNotExist(err) { t.Fatalf("read created registry: %v", err) } } func TestPlan14GenerationRegistryProductionReader(t *testing.T) { t.Setenv("CARLOS_DATA_DIR", t.TempDir()) path := filepath.Join(dataDir(), "registry.db") reg, err := registry.Open(path) if err != nil { t.Fatal(err) } owner := registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"} if err := reg.Upsert(registry.Route{Host: "tenant.example", ClaimOwnership: owner}); err != nil { t.Fatal(err) } if err := reg.Close(); err != nil { t.Fatal(err) } raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } snapshot := generationInventory{Payloads: []hostbox.GenerationPayload{{Inventory: hostbox.StopInventory{Host: "tenant.example", Incarnation: "inc", InstallationStamp: 1, Entries: []hostbox.StopInventoryEntry{{Kind: "route", ManagedRoot: "registry", RelativePath: "tenant.example", RowIdentity: "row", Ownership: "owner", Materialization: "materialized"}}}}}} if _, err := productionGenerationRegistryInventory(t.Context(), snapshot); err != nil { t.Fatal(err) } if _, err := productionGenerationRegistryInventory(t.Context(), generationInventory{}); err == nil { t.Fatal("empty root census concealed an unknown registry host") } snapshot.Payloads[0].Inventory.Entries = nil if _, err := productionGenerationRegistryInventory(t.Context(), snapshot); err == nil { t.Fatal("empty retained inventory erased a present row") } after, err := os.ReadFile(path) if err != nil || !bytes.Equal(raw, after) { t.Fatal("read-only source changed registry", err) } }