// 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 } // validatedItem borrows its content from the supplied reference set. Callers // must copy content before passing it to a callback that may retain it. type validatedItem 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 := validateOptionalSingleItem(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(append([]byte(nil), 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, bool, error) { normalized, mediaType, err := normalizeReferenceSpec(spec) if err != nil { return Item{}, false, err } item, present, err := validateOptionalSingleItem(references, normalized, mediaType) if err != nil || !present { return Item{}, present, err } return Item{ MediaType: item.mediaType, Content: append([]byte(nil), item.content...), }, true, nil } func (r *Resolver[V]) resolveUncached(references contracts.ReferenceSet) (preparedView[V], error) { item, present, err := validateOptionalSingleItem(references, r.config.Reference, r.mediaType) if err != nil { return preparedView[V]{}, err } if !present { return r.absent() } return r.load(append([]byte(nil), item.content...)) } func (r *Resolver[V]) absent() (preparedView[V], error) { value, err := r.config.Absent() if err != nil { return preparedView[V]{}, 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]{}, 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 validateOptionalSingleItem(references contracts.ReferenceSet, spec ReferenceSpec, acceptedMediaType string) (validatedItem, bool, error) { slot, present := references.Slots[spec.SlotName] if !present { return validatedItem{}, false, nil } if len(slot.Items) != 1 { return validatedItem{}, 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 validatedItem{}, true, fmt.Errorf("reference slot %q item media type is invalid", spec.SlotName) } mediaType = strings.ToLower(mediaType) if !strings.EqualFold(mediaType, acceptedMediaType) { return validatedItem{}, true, fmt.Errorf("reference slot %q item media type must be %s", spec.SlotName, acceptedMediaType) } if int64(len(item.Content)) > spec.MaxBytes { return validatedItem{}, true, fmt.Errorf("reference slot %q item is %d bytes, limit %d", spec.SlotName, len(item.Content), spec.MaxBytes) } return validatedItem{mediaType: mediaType, content: item.Content}, true, nil } func rawReferenceKey(item validatedItem) 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 }