615 lines
18 KiB
Go
615 lines
18 KiB
Go
package configkit
|
|
|
|
import (
|
|
"encoding"
|
|
"math"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
)
|
|
|
|
type projectionLimits struct {
|
|
depth int
|
|
nodes int
|
|
}
|
|
|
|
func defaultProjectionLimits() projectionLimits {
|
|
return projectionLimits{depth: maxDocumentDepth, nodes: maxDocumentNodes}
|
|
}
|
|
|
|
type projectionIssue struct {
|
|
code Code
|
|
path Path
|
|
message string
|
|
}
|
|
|
|
type valueVisit struct {
|
|
typeOf reflect.Type
|
|
kind reflect.Kind
|
|
ptr uintptr
|
|
}
|
|
|
|
type valueOperation struct {
|
|
schema *schemaDescriptor
|
|
limits projectionLimits
|
|
active map[valueVisit]bool
|
|
nodes int
|
|
}
|
|
|
|
func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (*document.Node, *projectionIssue) {
|
|
if schema == nil || schema.root == nil || !value.IsValid() || value.Type() != schema.root.typeOf {
|
|
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
|
|
}
|
|
operation := valueOperation{schema: schema, limits: limits, active: make(map[valueVisit]bool)}
|
|
return operation.project(value, schema.root, Path{}, 0)
|
|
}
|
|
|
|
func cloneConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (reflect.Value, *projectionIssue) {
|
|
if schema == nil || schema.root == nil || !value.IsValid() || value.Type() != schema.root.typeOf {
|
|
return reflect.Value{}, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
|
|
}
|
|
operation := valueOperation{schema: schema, limits: limits, active: make(map[valueVisit]bool)}
|
|
return operation.clone(value, schema.root, Path{}, 0)
|
|
}
|
|
|
|
func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
|
if issue := o.checkDepth(path, depth); issue != nil {
|
|
return nil, issue
|
|
}
|
|
if descriptor.kind == descriptorPointer {
|
|
if value.IsNil() {
|
|
return o.node(document.Null, path, depth)
|
|
}
|
|
leave, issue := o.enter(value, path)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
defer leave()
|
|
return o.project(value.Elem(), descriptor.element, path, depth)
|
|
}
|
|
|
|
switch descriptor.kind {
|
|
case descriptorStruct:
|
|
result, issue := o.node(document.Mapping, path, depth)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
result.Mapping = make([]document.Entry, 0, len(descriptor.fields))
|
|
seen := make(map[string]bool, len(descriptor.fields))
|
|
for _, member := range descriptor.members {
|
|
memberValue := value.Field(member.index)
|
|
if member.inline {
|
|
if isNilValue(memberValue) {
|
|
continue
|
|
}
|
|
inline, issue := o.project(memberValue, member.descriptor, path, depth)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
if inline.Kind != document.Mapping {
|
|
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "inline value did not project to a mapping"}
|
|
}
|
|
o.nodes-- // The flattened container is not retained in the output tree.
|
|
for _, entry := range inline.Mapping {
|
|
if seen[entry.Key] {
|
|
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: path.Key(entry.Key), message: "inline value conflicts with a declared field"}
|
|
}
|
|
seen[entry.Key] = true
|
|
result.Mapping = append(result.Mapping, entry)
|
|
}
|
|
continue
|
|
}
|
|
childPath := path.Key(member.name)
|
|
if seen[member.name] {
|
|
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: childPath, message: "inline value conflicts with a declared field"}
|
|
}
|
|
if issue := o.charge(childPath); issue != nil { // mapping key
|
|
return nil, issue
|
|
}
|
|
child, issue := o.project(memberValue, member.descriptor, childPath, depth+1)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
seen[member.name] = true
|
|
result.Mapping = append(result.Mapping, document.Entry{Key: member.name, Value: child})
|
|
}
|
|
for _, entry := range result.Mapping {
|
|
field := descriptor.inlineMap
|
|
if index, ok := descriptor.fieldByName[entry.Key]; ok {
|
|
field = &descriptor.fields[index]
|
|
}
|
|
if field != nil && field.secret {
|
|
entry.Value.Sensitive = true
|
|
}
|
|
}
|
|
return result, nil
|
|
case descriptorMap:
|
|
if value.IsNil() {
|
|
return o.node(document.Null, path, depth)
|
|
}
|
|
leave, issue := o.enter(value, path)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
defer leave()
|
|
result, issue := o.node(document.Mapping, path, depth)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
result.Mapping = make([]document.Entry, 0, value.Len())
|
|
keys := value.MapKeys()
|
|
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
|
return cmpString(left.String(), right.String())
|
|
})
|
|
for _, key := range keys {
|
|
childPath := path.Key(key.String())
|
|
if issue := o.charge(childPath); issue != nil {
|
|
return nil, issue
|
|
}
|
|
child, issue := o.project(value.MapIndex(key), descriptor.element, childPath, depth+1)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
result.Mapping = append(result.Mapping, document.Entry{Key: key.String(), Value: child})
|
|
}
|
|
return result, nil
|
|
case descriptorSlice:
|
|
if value.IsNil() {
|
|
return o.node(document.Null, path, depth)
|
|
}
|
|
leave, issue := o.enter(value, path)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
defer leave()
|
|
fallthrough
|
|
case descriptorArray:
|
|
result, issue := o.node(document.Sequence, path, depth)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
result.Sequence = make([]*document.Node, 0, value.Len())
|
|
for i := 0; i < value.Len(); i++ {
|
|
childPath := path.Index(i)
|
|
child, issue := o.project(value.Index(i), descriptor.element, childPath, depth+1)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
result.Sequence = append(result.Sequence, child)
|
|
}
|
|
return result, nil
|
|
case descriptorDynamic:
|
|
if value.IsNil() {
|
|
return o.node(document.Null, path, depth)
|
|
}
|
|
concrete := value.Elem()
|
|
dynamic, issue := o.dynamicDescriptor(concrete.Type(), path)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
return o.project(concrete, dynamic, path, depth)
|
|
case descriptorString:
|
|
node, issue := o.node(document.String, path, depth)
|
|
if issue == nil {
|
|
node.Text = value.String()
|
|
}
|
|
return node, issue
|
|
case descriptorBoolean:
|
|
node, issue := o.node(document.Boolean, path, depth)
|
|
if issue == nil {
|
|
node.Text = strconv.FormatBool(value.Bool())
|
|
}
|
|
return node, issue
|
|
case descriptorSigned:
|
|
node, issue := o.node(document.Number, path, depth)
|
|
if issue == nil {
|
|
node.Text = strconv.FormatInt(value.Int(), 10)
|
|
}
|
|
return node, issue
|
|
case descriptorUnsigned:
|
|
node, issue := o.node(document.Number, path, depth)
|
|
if issue == nil {
|
|
node.Text = strconv.FormatUint(value.Uint(), 10)
|
|
}
|
|
return node, issue
|
|
case descriptorFloat:
|
|
if math.IsNaN(value.Float()) || math.IsInf(value.Float(), 0) {
|
|
return nil, &projectionIssue{code: CodeSchemaNonFinite, path: path.clone(), message: "configuration float must be finite"}
|
|
}
|
|
node, issue := o.node(document.Number, path, depth)
|
|
if issue == nil {
|
|
node.Text = strconv.FormatFloat(value.Float(), 'g', -1, value.Type().Bits())
|
|
}
|
|
return node, issue
|
|
case descriptorDuration:
|
|
node, issue := o.node(document.String, path, depth)
|
|
if issue == nil {
|
|
node.Text = time.Duration(value.Int()).String()
|
|
}
|
|
return node, issue
|
|
case descriptorSecret:
|
|
node, issue := o.node(document.String, path, depth)
|
|
if issue == nil {
|
|
node.Text = value.Interface().(Secret).EnvName()
|
|
}
|
|
return node, issue
|
|
case descriptorText:
|
|
text, issue := marshalCanonicalText(value, path)
|
|
if issue != nil {
|
|
return nil, issue
|
|
}
|
|
node, nodeIssue := o.node(document.String, path, depth)
|
|
if nodeIssue == nil {
|
|
node.Text = text
|
|
}
|
|
return node, nodeIssue
|
|
default:
|
|
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "configuration type is not supported"}
|
|
}
|
|
}
|
|
|
|
func (o *valueOperation) clone(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (reflect.Value, *projectionIssue) {
|
|
if issue := o.checkDepth(path, depth); issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
if descriptor.kind == descriptorPointer {
|
|
if value.IsNil() {
|
|
return reflect.Zero(value.Type()), nil
|
|
}
|
|
leave, issue := o.enter(value, path)
|
|
if issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
defer leave()
|
|
child, issue := o.clone(value.Elem(), descriptor.element, path, depth)
|
|
if issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
result := reflect.New(value.Type().Elem())
|
|
result.Elem().Set(child)
|
|
return result, nil
|
|
}
|
|
|
|
switch descriptor.kind {
|
|
case descriptorStruct:
|
|
result := reflect.New(value.Type()).Elem()
|
|
for _, member := range descriptor.members {
|
|
memberPath, memberDepth := path, depth
|
|
if !member.inline {
|
|
memberPath, memberDepth = path.Key(member.name), depth+1
|
|
}
|
|
cloned, issue := o.clone(value.Field(member.index), member.descriptor, memberPath, memberDepth)
|
|
if issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
result.Field(member.index).Set(cloned)
|
|
}
|
|
return result, nil
|
|
case descriptorMap:
|
|
if value.IsNil() {
|
|
return reflect.Zero(value.Type()), nil
|
|
}
|
|
leave, issue := o.enter(value, path)
|
|
if issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
defer leave()
|
|
result := reflect.MakeMapWithSize(value.Type(), value.Len())
|
|
for _, key := range value.MapKeys() {
|
|
cloned, issue := o.clone(value.MapIndex(key), descriptor.element, path.Key(key.String()), depth+1)
|
|
if issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
result.SetMapIndex(key, cloned)
|
|
}
|
|
return result, nil
|
|
case descriptorSlice:
|
|
if value.IsNil() {
|
|
return reflect.Zero(value.Type()), nil
|
|
}
|
|
leave, issue := o.enter(value, path)
|
|
if issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
defer leave()
|
|
result := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
|
|
for i := 0; i < value.Len(); i++ {
|
|
cloned, issue := o.clone(value.Index(i), descriptor.element, path.Index(i), depth+1)
|
|
if issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
result.Index(i).Set(cloned)
|
|
}
|
|
return result, nil
|
|
case descriptorArray:
|
|
result := reflect.New(value.Type()).Elem()
|
|
for i := 0; i < value.Len(); i++ {
|
|
cloned, issue := o.clone(value.Index(i), descriptor.element, path.Index(i), depth+1)
|
|
if issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
result.Index(i).Set(cloned)
|
|
}
|
|
return result, nil
|
|
case descriptorDynamic:
|
|
if value.IsNil() {
|
|
return reflect.Zero(value.Type()), nil
|
|
}
|
|
concrete := value.Elem()
|
|
dynamic, issue := o.dynamicDescriptor(concrete.Type(), path)
|
|
if issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
cloned, issue := o.clone(concrete, dynamic, path, depth)
|
|
if issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
result := reflect.New(value.Type()).Elem()
|
|
result.Set(cloned)
|
|
return result, nil
|
|
case descriptorSecret:
|
|
result := reflect.New(value.Type()).Elem()
|
|
reference := value.Interface().(Secret)
|
|
if reference.specified {
|
|
result.Set(reflect.ValueOf(SecretEnv(reference.EnvName())))
|
|
}
|
|
return result, nil
|
|
case descriptorText:
|
|
text, issue := marshalCanonicalText(value, path)
|
|
if issue != nil {
|
|
return reflect.Value{}, issue
|
|
}
|
|
result := reflect.New(value.Type()).Elem()
|
|
unmarshaler := result.Addr().Interface().(encoding.TextUnmarshaler)
|
|
if err := unmarshaler.UnmarshalText([]byte(text)); err != nil {
|
|
return reflect.Value{}, &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar could not be reconstructed"}
|
|
}
|
|
return result, nil
|
|
case descriptorFloat:
|
|
if math.IsNaN(value.Float()) || math.IsInf(value.Float(), 0) {
|
|
return reflect.Value{}, &projectionIssue{code: CodeSchemaNonFinite, path: path.clone(), message: "configuration float must be finite"}
|
|
}
|
|
fallthrough
|
|
default:
|
|
result := reflect.New(value.Type()).Elem()
|
|
result.Set(value)
|
|
return result, nil
|
|
}
|
|
}
|
|
|
|
func (o *valueOperation) dynamicDescriptor(current reflect.Type, path Path) (*typeDescriptor, *projectionIssue) {
|
|
if current == secretType {
|
|
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"}
|
|
}
|
|
builder := schemaBuilder{cache: o.schema.types, building: make(map[reflect.Type]bool)}
|
|
descriptor, issue := builder.describe(current, path)
|
|
if issue != nil {
|
|
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration value has an unsupported type"}
|
|
}
|
|
if descriptorContainsSecret(descriptor, make(map[*typeDescriptor]bool)) {
|
|
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"}
|
|
}
|
|
return descriptor, nil
|
|
}
|
|
|
|
func descriptorContainsSecret(descriptor *typeDescriptor, visited map[*typeDescriptor]bool) bool {
|
|
if descriptor == nil || visited[descriptor] {
|
|
return false
|
|
}
|
|
if descriptor.kind == descriptorSecret {
|
|
return true
|
|
}
|
|
visited[descriptor] = true
|
|
if descriptorContainsSecret(descriptor.element, visited) {
|
|
return true
|
|
}
|
|
for _, member := range descriptor.members {
|
|
if descriptorContainsSecret(member.descriptor, visited) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*document.Node, *projectionIssue) {
|
|
if issue := o.checkDepth(path, depth); issue != nil {
|
|
return nil, issue
|
|
}
|
|
if issue := o.charge(path); issue != nil {
|
|
return nil, issue
|
|
}
|
|
return &document.Node{Kind: kind}, nil
|
|
}
|
|
|
|
func (o *valueOperation) charge(path Path) *projectionIssue {
|
|
o.nodes++
|
|
if o.limits.nodes >= 0 && o.nodes > o.limits.nodes {
|
|
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the node limit"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (o *valueOperation) checkDepth(path Path, depth int) *projectionIssue {
|
|
if depth > o.limits.depth {
|
|
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the depth limit"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (o *valueOperation) enter(value reflect.Value, path Path) (func(), *projectionIssue) {
|
|
visit, ok := visitFor(value)
|
|
if !ok {
|
|
return func() {}, nil
|
|
}
|
|
if o.active[visit] {
|
|
return nil, &projectionIssue{code: CodeSchemaCycle, path: path.clone(), message: "configuration values must not contain cycles"}
|
|
}
|
|
o.active[visit] = true
|
|
return func() { delete(o.active, visit) }, nil
|
|
}
|
|
|
|
func visitFor(value reflect.Value) (valueVisit, bool) {
|
|
if isNilValue(value) {
|
|
return valueVisit{}, false
|
|
}
|
|
visit := valueVisit{typeOf: value.Type(), kind: value.Kind()}
|
|
switch value.Kind() {
|
|
case reflect.Pointer, reflect.Slice:
|
|
if value.Kind() == reflect.Slice && value.Len() == 0 {
|
|
return valueVisit{}, false
|
|
}
|
|
visit.ptr = value.Pointer()
|
|
case reflect.Map:
|
|
if value.Len() == 0 {
|
|
return valueVisit{}, false
|
|
}
|
|
visit.ptr = uintptr(value.UnsafePointer())
|
|
default:
|
|
return valueVisit{}, false
|
|
}
|
|
return visit, visit.ptr != 0
|
|
}
|
|
|
|
func isNilValue(value reflect.Value) bool {
|
|
switch value.Kind() {
|
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
|
return value.IsNil()
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIssue) {
|
|
var marshaler encoding.TextMarshaler
|
|
if value.CanInterface() {
|
|
marshaler, _ = value.Interface().(encoding.TextMarshaler)
|
|
}
|
|
if marshaler == nil {
|
|
addressable := reflect.New(value.Type())
|
|
addressable.Elem().Set(value)
|
|
marshaler, _ = addressable.Interface().(encoding.TextMarshaler)
|
|
}
|
|
if marshaler == nil {
|
|
return "", &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar has no usable text marshaler"}
|
|
}
|
|
text, err := marshaler.MarshalText()
|
|
if err != nil || !utf8.Valid(text) {
|
|
return "", &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar could not be projected"}
|
|
}
|
|
return string(text), nil
|
|
}
|
|
|
|
func cmpString(left, right string) int {
|
|
switch {
|
|
case left < right:
|
|
return -1
|
|
case left > right:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
type documentDeltaKind uint8
|
|
|
|
const (
|
|
documentAdded documentDeltaKind = iota + 1
|
|
documentRemoved
|
|
documentReplaced
|
|
)
|
|
|
|
type documentDelta struct {
|
|
path Path
|
|
kind documentDeltaKind
|
|
}
|
|
|
|
func compareDocumentTrees(before, after *document.Node) []documentDelta {
|
|
var deltas []documentDelta
|
|
compareDocumentNode(before, after, Path{}, &deltas)
|
|
slices.SortFunc(deltas, func(left, right documentDelta) int {
|
|
if order := cmpString(left.path.String(), right.path.String()); order != 0 {
|
|
return order
|
|
}
|
|
return int(left.kind) - int(right.kind)
|
|
})
|
|
return deltas
|
|
}
|
|
|
|
func compareDocumentNode(before, after *document.Node, path Path, deltas *[]documentDelta) {
|
|
if before == nil && after == nil {
|
|
return
|
|
}
|
|
if before == nil {
|
|
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentAdded})
|
|
return
|
|
}
|
|
if after == nil {
|
|
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentRemoved})
|
|
return
|
|
}
|
|
if before.Kind != after.Kind {
|
|
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
|
return
|
|
}
|
|
switch before.Kind {
|
|
case document.Mapping:
|
|
beforeEntries := make(map[string]*document.Node, len(before.Mapping))
|
|
afterEntries := make(map[string]*document.Node, len(after.Mapping))
|
|
keys := make(map[string]bool, len(before.Mapping)+len(after.Mapping))
|
|
for _, entry := range before.Mapping {
|
|
beforeEntries[entry.Key] = entry.Value
|
|
keys[entry.Key] = true
|
|
}
|
|
for _, entry := range after.Mapping {
|
|
afterEntries[entry.Key] = entry.Value
|
|
keys[entry.Key] = true
|
|
}
|
|
ordered := make([]string, 0, len(keys))
|
|
for key := range keys {
|
|
ordered = append(ordered, key)
|
|
}
|
|
slices.Sort(ordered)
|
|
for _, key := range ordered {
|
|
compareDocumentNode(beforeEntries[key], afterEntries[key], path.Key(key), deltas)
|
|
}
|
|
case document.Sequence:
|
|
if !documentNodesEqual(before, after) {
|
|
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
|
}
|
|
default:
|
|
if before.Text != after.Text {
|
|
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
|
}
|
|
}
|
|
}
|
|
|
|
func documentNodesEqual(left, right *document.Node) bool {
|
|
if left == nil || right == nil {
|
|
return left == right
|
|
}
|
|
if left.Kind != right.Kind || left.Text != right.Text || len(left.Mapping) != len(right.Mapping) || len(left.Sequence) != len(right.Sequence) {
|
|
return false
|
|
}
|
|
if left.Kind == document.Mapping {
|
|
leftMap := make(map[string]*document.Node, len(left.Mapping))
|
|
for _, entry := range left.Mapping {
|
|
leftMap[entry.Key] = entry.Value
|
|
}
|
|
for _, entry := range right.Mapping {
|
|
if !documentNodesEqual(leftMap[entry.Key], entry.Value) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
for i := range left.Sequence {
|
|
if !documentNodesEqual(left.Sequence[i], right.Sequence[i]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|