269 lines
8.5 KiB
Go
269 lines
8.5 KiB
Go
// 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
|
|
}
|