Add shared D&D registry resolver

This commit is contained in:
2026-08-04 13:20:20 +00:00
parent 5002864e88
commit 84c0758455
2 changed files with 610 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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)
}
}