From 84c07584551911962c08bbbc25307e549514c5ab Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 4 Aug 2026 13:20:20 +0000 Subject: [PATCH] Add shared D&D registry resolver --- .../dnd/shared/registryresolver/resolver.go | 254 +++++++++++++ .../shared/registryresolver/resolver_test.go | 356 ++++++++++++++++++ 2 files changed, 610 insertions(+) create mode 100644 internal/modules/dnd/shared/registryresolver/resolver.go create mode 100644 internal/modules/dnd/shared/registryresolver/resolver_test.go diff --git a/internal/modules/dnd/shared/registryresolver/resolver.go b/internal/modules/dnd/shared/registryresolver/resolver.go new file mode 100644 index 0000000..bca9109 --- /dev/null +++ b/internal/modules/dnd/shared/registryresolver/resolver.go @@ -0,0 +1,254 @@ +// Package registryresolver provides the shared reference-selection and caching +// mechanics used by immutable D&D registry views. +package registryresolver + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "mime" + "strings" + "sync" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +// ReferenceSpec describes one optional, single-item registry reference. +type ReferenceSpec struct { + SlotName string + AcceptedMediaType string + MaxBytes int64 +} + +// Item is an owned reference payload. MediaType is the parsed, normalized +// media type without parameters. Reference provenance and caller digests are +// intentionally excluded. +type Item struct { + MediaType string + Content []byte +} + +// Config supplies the domain-owned operations needed to prepare immutable +// registry views. Absent and Load must return values whose mutable state is not +// exposed to callers. Load receives owned bytes and may retain them. Errors +// returned by either callback must be bounded and must not contain input data. +type Config[V any] struct { + Reference ReferenceSpec + Absent func() (V, error) + Load func([]byte) (V, error) + SemanticIdentity func(V) string +} + +type preparedView[V any] struct { + value V + bound bool + identity string +} + +type semanticKey struct { + bound bool + identity string +} + +// Resolver retains one immutable construction-time view and memoizes valid +// operation-time views by raw reference content and semantic identity. +type Resolver[V any] struct { + config Config[V] + seeded preparedView[V] + mediaType string + slotName string + + mu sync.Mutex + rawCache map[string]preparedView[V] + semanticCache map[semanticKey]preparedView[V] +} + +// New validates the configuration and construction-time reference. A present +// slot with no items is treated as an absent generated-reference placeholder +// only at this construction boundary. +func New[V any](config Config[V], references contracts.ReferenceSet) (*Resolver[V], error) { + normalized, mediaType, err := normalizeConfig(config) + if err != nil { + return nil, err + } + resolver := &Resolver[V]{ + config: normalized, + mediaType: mediaType, + slotName: normalized.Reference.SlotName, + rawCache: make(map[string]preparedView[V]), + semanticCache: make(map[semanticKey]preparedView[V]), + } + + if slot, present := references.Slots[resolver.slotName]; present && len(slot.Items) == 0 { + resolver.seeded, err = resolver.absent() + } else { + resolver.seeded, err = resolver.resolveUncached(references) + } + if err != nil { + return nil, err + } + return resolver, nil +} + +// Seeded returns the immutable construction-time view. +func (r *Resolver[V]) Seeded() V { + if r == nil { + var zero V + return zero + } + return r.seeded.value +} + +// Resolve returns the construction-time view when the operation does not +// supply the configured slot. A present slot is validated and loaded as an +// operation-time override. +func (r *Resolver[V]) Resolve(references contracts.ReferenceSet) (V, error) { + if r == nil { + var zero V + return zero, fmt.Errorf("registry resolver must not be nil") + } + if _, present := references.Slots[r.slotName]; !present { + return r.seeded.value, nil + } + + item, _, err := resolveOptionalSingleItem(references, r.config.Reference, r.mediaType) + if err != nil { + var zero V + return zero, err + } + rawKey := rawReferenceKey(item) + + r.mu.Lock() + defer r.mu.Unlock() + if cached, ok := r.rawCache[rawKey]; ok { + return cached.value, nil + } + + resolved, err := r.load(item.Content) + if err != nil { + var zero V + return zero, err + } + if sameIdentity(r.seeded, resolved) { + r.rawCache[rawKey] = r.seeded + return r.seeded.value, nil + } + + key := semanticKey{bound: resolved.bound, identity: resolved.identity} + if cached, ok := r.semanticCache[key]; ok { + r.rawCache[rawKey] = cached + return cached.value, nil + } + r.semanticCache[key] = resolved + r.rawCache[rawKey] = resolved + return resolved.value, nil +} + +// ResolveOptionalSingleItem validates and copies one optional registry item. +// A missing slot returns present=false. A present slot must contain exactly one +// item, even when it represents an operation-time generated reference. +func ResolveOptionalSingleItem(references contracts.ReferenceSet, spec ReferenceSpec) (item Item, present bool, err error) { + normalized, mediaType, err := normalizeReferenceSpec(spec) + if err != nil { + return Item{}, false, err + } + return resolveOptionalSingleItem(references, normalized, mediaType) +} + +func (r *Resolver[V]) resolveUncached(references contracts.ReferenceSet) (preparedView[V], error) { + item, present, err := resolveOptionalSingleItem(references, r.config.Reference, r.mediaType) + if err != nil { + return preparedView[V]{}, err + } + if !present { + return r.absent() + } + return r.load(item.Content) +} + +func (r *Resolver[V]) absent() (preparedView[V], error) { + value, err := r.config.Absent() + if err != nil { + return preparedView[V]{}, fmt.Errorf("prepare absent reference slot %q: %w", r.slotName, err) + } + return preparedView[V]{value: value, identity: r.config.SemanticIdentity(value)}, nil +} + +func (r *Resolver[V]) load(content []byte) (preparedView[V], error) { + value, err := r.config.Load(content) + if err != nil { + return preparedView[V]{}, fmt.Errorf("load reference slot %q: %w", r.slotName, err) + } + identity := strings.TrimSpace(r.config.SemanticIdentity(value)) + if identity == "" { + return preparedView[V]{}, fmt.Errorf("load reference slot %q: semantic identity must not be empty", r.slotName) + } + return preparedView[V]{value: value, bound: true, identity: identity}, nil +} + +func normalizeConfig[V any](config Config[V]) (Config[V], string, error) { + reference, mediaType, err := normalizeReferenceSpec(config.Reference) + if err != nil { + return Config[V]{}, "", err + } + if config.Absent == nil { + return Config[V]{}, "", fmt.Errorf("registry resolver absent-view callback must not be nil") + } + if config.Load == nil { + return Config[V]{}, "", fmt.Errorf("registry resolver loader must not be nil") + } + if config.SemanticIdentity == nil { + return Config[V]{}, "", fmt.Errorf("registry resolver semantic-identity callback must not be nil") + } + config.Reference = reference + return config, mediaType, nil +} + +func normalizeReferenceSpec(spec ReferenceSpec) (ReferenceSpec, string, error) { + spec.SlotName = strings.TrimSpace(spec.SlotName) + if spec.SlotName == "" { + return ReferenceSpec{}, "", fmt.Errorf("registry reference slot name must not be empty") + } + mediaType, _, err := mime.ParseMediaType(spec.AcceptedMediaType) + if err != nil || strings.TrimSpace(mediaType) == "" { + return ReferenceSpec{}, "", fmt.Errorf("registry reference slot %q accepted media type is invalid", spec.SlotName) + } + mediaType = strings.ToLower(mediaType) + if spec.MaxBytes <= 0 { + return ReferenceSpec{}, "", fmt.Errorf("registry reference slot %q maximum size must be positive", spec.SlotName) + } + spec.AcceptedMediaType = mediaType + return spec, mediaType, nil +} + +func resolveOptionalSingleItem(references contracts.ReferenceSet, spec ReferenceSpec, acceptedMediaType string) (Item, bool, error) { + slot, present := references.Slots[spec.SlotName] + if !present { + return Item{}, false, nil + } + if len(slot.Items) != 1 { + return Item{}, true, fmt.Errorf("reference slot %q must contain exactly one item", spec.SlotName) + } + item := slot.Items[0] + mediaType, _, err := mime.ParseMediaType(item.MediaType) + if err != nil { + return Item{}, true, fmt.Errorf("reference slot %q item media type is invalid", spec.SlotName) + } + mediaType = strings.ToLower(mediaType) + if !strings.EqualFold(mediaType, acceptedMediaType) { + return Item{}, true, fmt.Errorf("reference slot %q item media type must be %s", spec.SlotName, acceptedMediaType) + } + if int64(len(item.Content)) > spec.MaxBytes { + return Item{}, true, fmt.Errorf("reference slot %q item is %d bytes, limit %d", spec.SlotName, len(item.Content), spec.MaxBytes) + } + return Item{MediaType: mediaType, Content: append([]byte(nil), item.Content...)}, true, nil +} + +func rawReferenceKey(item Item) string { + sum := sha256.Sum256(item.Content) + return item.MediaType + "\x00sha256:" + hex.EncodeToString(sum[:]) +} + +func sameIdentity[V any](first, second preparedView[V]) bool { + return first.bound == second.bound && first.identity == second.identity +} diff --git a/internal/modules/dnd/shared/registryresolver/resolver_test.go b/internal/modules/dnd/shared/registryresolver/resolver_test.go new file mode 100644 index 0000000..ddd48b8 --- /dev/null +++ b/internal/modules/dnd/shared/registryresolver/resolver_test.go @@ -0,0 +1,356 @@ +package registryresolver_test + +import ( + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/registryresolver" +) + +const ( + testSlot = "registry" + testMediaType = "application/json" +) + +type immutableView struct { + value string + identity string +} + +func TestResolverHandlesConstructionStateFallbackAndOverrides(t *testing.T) { + config := resolverConfig(nil) + absent, err := registryresolver.New(config, contracts.ReferenceSet{}) + if err != nil { + t.Fatal(err) + } + placeholder, err := registryresolver.New(config, referenceSet()) + if err != nil { + t.Fatal(err) + } + if absent.Seeded().value != "absent" || placeholder.Seeded().value != "absent" { + t.Fatalf("seeded views = %#v / %#v, want absent", absent.Seeded(), placeholder.Seeded()) + } + + seedContent := []byte("seeded") + seeded, err := registryresolver.New(config, referenceSet(referenceItem(seedContent))) + if err != nil { + t.Fatal(err) + } + fallback, err := seeded.Resolve(contracts.ReferenceSet{}) + if err != nil || fallback != seeded.Seeded() { + t.Fatalf("fallback = %p, %v; seeded = %p", fallback, err, seeded.Seeded()) + } + override, err := seeded.Resolve(referenceSet(referenceItem([]byte("override")))) + if err != nil || override == seeded.Seeded() || override.value != "override" { + t.Fatalf("override = %#v, %v", override, err) + } + if _, err := seeded.Resolve(referenceSet()); err == nil || !strings.Contains(err.Error(), "exactly one item") { + t.Fatalf("operation placeholder error = %v", err) + } + if _, err := registryresolver.New(config, referenceSet(referenceItem([]byte("one")), referenceItem([]byte("two")))); err == nil || !strings.Contains(err.Error(), "exactly one item") { + t.Fatalf("construction cardinality error = %v", err) + } +} + +func TestResolveOptionalSingleItemValidatesAndOwnsContent(t *testing.T) { + spec := referenceSpec() + if item, present, err := registryresolver.ResolveOptionalSingleItem(contracts.ReferenceSet{}, spec); err != nil || present || item.Content != nil { + t.Fatalf("absent item = %#v, %t, %v", item, present, err) + } + + content := []byte("approved") + set := referenceSet(contracts.ReferenceItem{ + SlotName: testSlot, + MediaType: "Application/JSON; Charset=UTF-8", + Content: content, + Digest: "sha256:caller-supplied", + Origin: contracts.ReferenceOrigin{Type: "file", URI: "/private/campaign.json"}, + }) + item, present, err := registryresolver.ResolveOptionalSingleItem(set, spec) + if err != nil || !present || item.MediaType != testMediaType || string(item.Content) != "approved" { + t.Fatalf("resolved item = %#v, %t, %v", item, present, err) + } + content[0] = 'X' + if string(item.Content) != "approved" { + t.Fatalf("resolved item retained caller bytes: %q", item.Content) + } + + secret := "private campaign material" + for _, test := range []struct { + name string + spec registryresolver.ReferenceSpec + set contracts.ReferenceSet + want string + }{ + {"blank slot", registryresolver.ReferenceSpec{AcceptedMediaType: testMediaType, MaxBytes: 64}, contracts.ReferenceSet{}, "slot name"}, + {"invalid accepted media", registryresolver.ReferenceSpec{SlotName: testSlot, AcceptedMediaType: "not a type", MaxBytes: 64}, contracts.ReferenceSet{}, "accepted media type"}, + {"invalid maximum", registryresolver.ReferenceSpec{SlotName: testSlot, AcceptedMediaType: testMediaType}, contracts.ReferenceSet{}, "maximum size"}, + {"empty slot", spec, referenceSet(), "exactly one item"}, + {"multiple items", spec, referenceSet(referenceItem([]byte("one")), referenceItem([]byte("two"))), "exactly one item"}, + {"invalid item media", spec, referenceSet(contracts.ReferenceItem{MediaType: secret, Content: []byte(secret)}), "media type is invalid"}, + {"wrong item media", spec, referenceSet(contracts.ReferenceItem{MediaType: "text/plain", Content: []byte(secret)}), "must be application/json"}, + {"oversized", spec, referenceSet(referenceItem([]byte(strings.Repeat("x", 65)))), "limit 64"}, + } { + t.Run(test.name, func(t *testing.T) { + _, _, err := registryresolver.ResolveOptionalSingleItem(test.set, test.spec) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), "/private/") { + t.Fatalf("error exposed reference content or provenance: %v", err) + } + }) + } +} + +func TestResolverReusesRawSemanticAndSeededViewsByActualContent(t *testing.T) { + var loads atomic.Int32 + resolver, err := registryresolver.New(resolverConfig(&loads), referenceSet(referenceItem([]byte("alpha")))) + if err != nil { + t.Fatal(err) + } + if loads.Load() != 1 { + t.Fatalf("construction loads = %d, want 1", loads.Load()) + } + + seedEquivalent := referenceSet(referenceItem([]byte(" alpha "))) + resolvedSeed, err := resolver.Resolve(seedEquivalent) + if err != nil || resolvedSeed != resolver.Seeded() { + t.Fatalf("semantic seeded reuse = %p, %v; want %p", resolvedSeed, err, resolver.Seeded()) + } + if _, err := resolver.Resolve(seedEquivalent); err != nil || loads.Load() != 2 { + t.Fatalf("raw seeded reuse loads = %d, err = %v; want 2", loads.Load(), err) + } + + betaSet := referenceSet(referenceItem([]byte("beta"))) + beta, err := resolver.Resolve(betaSet) + if err != nil { + t.Fatal(err) + } + betaAgain, err := resolver.Resolve(betaSet) + if err != nil || betaAgain != beta { + t.Fatalf("raw reuse = %p / %p, %v", beta, betaAgain, err) + } + betaEquivalent, err := resolver.Resolve(referenceSet(referenceItem([]byte("\n beta \t")))) + if err != nil || betaEquivalent != beta { + t.Fatalf("semantic reuse = %p / %p, %v", beta, betaEquivalent, err) + } + mediaVariant := referenceItem([]byte("beta")) + mediaVariant.MediaType = "APPLICATION/JSON; charset=utf-8" + if got, err := resolver.Resolve(referenceSet(mediaVariant)); err != nil || got != beta { + t.Fatalf("normalized media raw reuse = %p / %p, %v", beta, got, err) + } + if loads.Load() != 4 { + t.Fatalf("loads after raw and semantic reuse = %d, want 4", loads.Load()) + } + + first := referenceItem([]byte("gamma")) + second := referenceItem([]byte("delta")) + first.Digest = "sha256:" + strings.Repeat("0", 64) + second.Digest = first.Digest + gamma, err := resolver.Resolve(referenceSet(first)) + if err != nil { + t.Fatal(err) + } + delta, err := resolver.Resolve(referenceSet(second)) + if err != nil || gamma == delta || gamma.value == delta.value { + t.Fatalf("caller digest aliased different bytes: %#v / %#v, %v", gamma, delta, err) + } +} + +func TestResolverSerializesConcurrentLoads(t *testing.T) { + var loads atomic.Int32 + resolver, err := registryresolver.New(resolverConfig(&loads), contracts.ReferenceSet{}) + if err != nil { + t.Fatal(err) + } + loads.Store(0) + references := referenceSet(referenceItem([]byte("shared"))) + + const callers = 32 + results := make(chan *immutableView, callers) + errors := make(chan error, callers) + var group sync.WaitGroup + for range callers { + group.Add(1) + go func() { + defer group.Done() + view, err := resolver.Resolve(references) + if err != nil { + errors <- err + return + } + results <- view + }() + } + group.Wait() + close(results) + close(errors) + for err := range errors { + t.Error(err) + } + var first *immutableView + for result := range results { + if first == nil { + first = result + } + if result != first { + t.Fatalf("concurrent result = %p, want %p", result, first) + } + } + if loads.Load() != 1 { + t.Fatalf("concurrent loads = %d, want 1", loads.Load()) + } +} + +func TestResolverDoesNotCacheFailuresOrRetainCallerState(t *testing.T) { + loaderFailure := errors.New("loader unavailable") + var loads atomic.Int32 + var loadedBytes []byte + config := resolverConfig(&loads) + config.Load = func(content []byte) (*immutableView, error) { + loads.Add(1) + loadedBytes = content + if string(content) == "fail" { + return nil, loaderFailure + } + value := strings.TrimSpace(string(content)) + return &immutableView{value: value, identity: value}, nil + } + + seedBytes := []byte("seed") + seedReferences := referenceSet(referenceItem(seedBytes)) + resolver, err := registryresolver.New(config, seedReferences) + if err != nil { + t.Fatal(err) + } + seedBytes[0] = 'X' + delete(seedReferences.Slots, testSlot) + if resolver.Seeded().value != "seed" || string(loadedBytes) != "seed" { + t.Fatalf("construction retained caller state: %#v / %q", resolver.Seeded(), loadedBytes) + } + + failing := referenceSet(referenceItem([]byte("fail"))) + for range 2 { + if _, err := resolver.Resolve(failing); !errors.Is(err, loaderFailure) { + t.Fatalf("loader error = %v", err) + } + } + if loads.Load() != 3 { + t.Fatalf("loads after repeated failure = %d, want 3", loads.Load()) + } + + operationBytes := []byte("operation") + operationReferences := referenceSet(referenceItem(operationBytes)) + view, err := resolver.Resolve(operationReferences) + if err != nil { + t.Fatal(err) + } + operationBytes[0] = 'X' + operationReferences.Slots[testSlot] = contracts.ResolvedReferenceSlot{} + if view.value != "operation" || string(loadedBytes) != "operation" { + t.Fatalf("operation retained caller state: %#v / %q", view, loadedBytes) + } + + var invalidLoads atomic.Int32 + config.Load = func([]byte) (*immutableView, error) { + invalidLoads.Add(1) + return &immutableView{value: "invalid"}, nil + } + missingIdentity, err := registryresolver.New(config, contracts.ReferenceSet{}) + if err != nil { + t.Fatal(err) + } + for range 2 { + if _, err := missingIdentity.Resolve(referenceSet(referenceItem([]byte("invalid")))); err == nil || !strings.Contains(err.Error(), "semantic identity") { + t.Fatalf("missing identity error = %v", err) + } + } + if invalidLoads.Load() != 2 { + t.Fatalf("invalid view loads = %d, want 2", invalidLoads.Load()) + } +} + +func TestNewValidatesCallbacksAndDoesNotCacheAbsentFailure(t *testing.T) { + base := resolverConfig(nil) + for _, test := range []struct { + name string + mutate func(*registryresolver.Config[*immutableView]) + want string + }{ + {"absent", func(config *registryresolver.Config[*immutableView]) { config.Absent = nil }, "absent-view"}, + {"loader", func(config *registryresolver.Config[*immutableView]) { config.Load = nil }, "loader"}, + {"identity", func(config *registryresolver.Config[*immutableView]) { config.SemanticIdentity = nil }, "semantic-identity"}, + } { + t.Run(test.name, func(t *testing.T) { + config := base + test.mutate(&config) + if _, err := registryresolver.New(config, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("New() error = %v, want %q", err, test.want) + } + }) + } + + absentFailure := errors.New("absent unavailable") + base.Absent = func() (*immutableView, error) { return nil, absentFailure } + if _, err := registryresolver.New(base, contracts.ReferenceSet{}); !errors.Is(err, absentFailure) { + t.Fatalf("absent error = %v", err) + } +} + +func resolverConfig(loads *atomic.Int32) registryresolver.Config[*immutableView] { + return registryresolver.Config[*immutableView]{ + Reference: referenceSpec(), + Absent: func() (*immutableView, error) { + return &immutableView{value: "absent", identity: "absent"}, nil + }, + Load: func(content []byte) (*immutableView, error) { + if loads != nil { + loads.Add(1) + } + value := strings.TrimSpace(string(content)) + return &immutableView{value: value, identity: "identity:" + value}, nil + }, + SemanticIdentity: func(view *immutableView) string { + if view == nil { + return "" + } + return view.identity + }, + } +} + +func referenceSpec() registryresolver.ReferenceSpec { + return registryresolver.ReferenceSpec{SlotName: testSlot, AcceptedMediaType: testMediaType, MaxBytes: 64} +} + +func referenceItem(content []byte) contracts.ReferenceItem { + return contracts.ReferenceItem{SlotName: testSlot, MediaType: testMediaType, Content: content} +} + +func referenceSet(items ...contracts.ReferenceItem) contracts.ReferenceSet { + return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ + testSlot: {Slot: contracts.ReferenceSlot{Name: testSlot}, Items: items}, + }} +} + +func TestErrorsDoNotFormatReferenceData(t *testing.T) { + secret := "hidden campaign notes" + config := resolverConfig(nil) + config.Load = func([]byte) (*immutableView, error) { return nil, fmt.Errorf("safe loader failure") } + resolver, err := registryresolver.New(config, contracts.ReferenceSet{}) + if err != nil { + t.Fatal(err) + } + item := referenceItem([]byte(secret)) + item.Origin.URI = "/private/campaign.json" + _, err = resolver.Resolve(referenceSet(item)) + if err == nil || strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), item.Origin.URI) { + t.Fatalf("error exposed reference data: %v", err) + } +}