Separate projection cloning and value comparison
This commit is contained in:
159
clone.go
Normal file
159
clone.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
|
)
|
||||||
|
|
||||||
|
// secretCloneMode distinguishes fresh unresolved references from publication of
|
||||||
|
// already validated private state, which must not trigger another lookup.
|
||||||
|
type secretCloneMode uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
cloneUnresolvedSecrets secretCloneMode = iota
|
||||||
|
cloneValidatedSecrets
|
||||||
|
)
|
||||||
|
|
||||||
|
func cloneConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (reflect.Value, *projectionIssue) {
|
||||||
|
return cloneConfigurationWithMode(schema, value, limits, cloneUnresolvedSecrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneConfigurationWithMode(schema *schemaDescriptor, value reflect.Value, limits projectionLimits, mode secretCloneMode) (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), cloneMode: mode}
|
||||||
|
return operation.clone(value, schema.root, Path{}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 o.cloneMode == cloneValidatedSecrets {
|
||||||
|
result.Set(value)
|
||||||
|
} else 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ values: [one, two]
|
|||||||
if len(reversedDiagnostics) != 0 {
|
if len(reversedDiagnostics) != 0 {
|
||||||
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
|
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
|
||||||
}
|
}
|
||||||
if deltas := compareDocumentTrees(composed.root, reversed.root); len(deltas) != 0 {
|
if deltas := compareConfigurationValues(composed.root, reversed.root); len(deltas) != 0 {
|
||||||
t.Fatalf("input order changed effective values: %#v", deltas)
|
t.Fatalf("input order changed effective values: %#v", deltas)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func FuzzCompositionAndOverlay(f *testing.F) {
|
|||||||
if len(reversedDiagnostics) != 0 {
|
if len(reversedDiagnostics) != 0 {
|
||||||
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
|
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
|
||||||
}
|
}
|
||||||
if differences := compareDocumentTrees(composed.root, reversed.root); len(differences) != 0 {
|
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
|
||||||
t.Fatalf("composition order changed effective values: %#v", differences)
|
t.Fatalf("composition order changed effective values: %#v", differences)
|
||||||
}
|
}
|
||||||
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ module and package documentation boundary. Its implemented public primitives are
|
|||||||
in `override.go`, sharing overlay, source, byte, node, and depth policies;
|
in `override.go`, sharing overlay, source, byte, node, and depth policies;
|
||||||
- a per-operation private reflection descriptor graph in `schema.go` for supported
|
- a per-operation private reflection descriptor graph in `schema.go` for supported
|
||||||
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
|
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
|
||||||
- descriptor-driven canonical projection, detached typed cloning, cycle/limit
|
- descriptor-driven canonical projection in `projection.go`, detached typed
|
||||||
enforcement, and structural comparison in `projection.go`;
|
cloning with explicit secret-state modes in `clone.go`, shared reflection and
|
||||||
|
cycle/limit state in `value_operation.go`, and configuration-value comparison
|
||||||
|
in `document_compare.go`; validation separately audits sensitivity and secrets;
|
||||||
- strict schema-driven decoding over detached defaults in `decode.go`, including
|
- strict schema-driven decoding over detached defaults in `decode.go`, including
|
||||||
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
|
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
|
||||||
baseline/final trees, and initial default/input origins;
|
baseline/final trees, and initial default/input origins;
|
||||||
|
|||||||
@@ -204,7 +204,16 @@ or predicate coverage changes. Do not add tests that assert walker call counts.
|
|||||||
|
|
||||||
## Stage 5 — Separate typed projection, cloning, and comparison
|
## Stage 5 — Separate typed projection, cloning, and comparison
|
||||||
|
|
||||||
Status: pending.
|
Status: complete.
|
||||||
|
|
||||||
|
Completion: projection, detached cloning, shared reflection state, and value-only
|
||||||
|
comparison now have separate owners. Named secret clone modes preserve the two
|
||||||
|
ownership barriers. Validation combines value and sensitivity comparison in a
|
||||||
|
named helper and separately audits resolved secret state. Standard `cmp.Compare`
|
||||||
|
replaces the custom comparator; tests changed only for private symbol renames.
|
||||||
|
Full offline baseline and race suites passed with Go 1.26.7 on Linux/amd64,
|
||||||
|
including codec/cycle/limit, callback ownership, sensitivity, and secret-state
|
||||||
|
regressions.
|
||||||
|
|
||||||
Evidence: [projection.go](../../projection.go) combines roughly 622 lines of
|
Evidence: [projection.go](../../projection.go) combines roughly 622 lines of
|
||||||
projection, detached cloning, dynamic schema handling, cycle/limit enforcement,
|
projection, detached cloning, dynamic schema handling, cycle/limit enforcement,
|
||||||
|
|||||||
110
document_compare.go
Normal file
110
document_compare.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type documentDeltaKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
documentAdded documentDeltaKind = iota + 1
|
||||||
|
documentRemoved
|
||||||
|
documentReplaced
|
||||||
|
)
|
||||||
|
|
||||||
|
type documentDelta struct {
|
||||||
|
path Path
|
||||||
|
kind documentDeltaKind
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareConfigurationValues ignores locations and sensitivity metadata. Mapping
|
||||||
|
// order is immaterial; sequences are atomic for change reporting.
|
||||||
|
func compareConfigurationValues(before, after *document.Node) []documentDelta {
|
||||||
|
var deltas []documentDelta
|
||||||
|
compareConfigurationValue(before, after, Path{}, &deltas)
|
||||||
|
slices.SortFunc(deltas, func(left, right documentDelta) int {
|
||||||
|
if order := cmp.Compare(left.path.String(), right.path.String()); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
return int(left.kind) - int(right.kind)
|
||||||
|
})
|
||||||
|
return deltas
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareConfigurationValue(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 {
|
||||||
|
compareConfigurationValue(beforeEntries[key], afterEntries[key], path.Key(key), deltas)
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
if !configurationValuesEqual(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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// configurationValuesEqual compares only configuration values, not provenance or
|
||||||
|
// sensitivity. Callers auditing validation must check sensitivity separately.
|
||||||
|
func configurationValuesEqual(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 !configurationValuesEqual(leftMap[entry.Key], entry.Value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range left.Sequence {
|
||||||
|
if !configurationValuesEqual(left.Sequence[i], right.Sequence[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
|||||||
// explicitly, including canonical scalar conversions, before domain changes.
|
// explicitly, including canonical scalar conversions, before domain changes.
|
||||||
replayed, _ := documentAtPath(working, path)
|
replayed, _ := documentAtPath(working, path)
|
||||||
decoded, _ := documentAtPath(r.input, path)
|
decoded, _ := documentAtPath(r.input, path)
|
||||||
if !documentNodesEqual(replayed, decoded) {
|
if !configurationValuesEqual(replayed, decoded) {
|
||||||
origin := cloneOrigin(r.inputOrigins[path.String()])
|
origin := cloneOrigin(r.inputOrigins[path.String()])
|
||||||
origin.Operation = ChangeDecoding
|
origin.Operation = ChangeDecoding
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
|
|||||||
2
load.go
2
load.go
@@ -275,7 +275,7 @@ func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ..
|
|||||||
}
|
}
|
||||||
// A read-only validator can replace a value with an equal borrowed object.
|
// A read-only validator can replace a value with an equal borrowed object.
|
||||||
// Projected mutation checks cannot detect that alias, so detach at publication.
|
// Projected mutation checks cannot detect that alias, so detach at publication.
|
||||||
published, ownershipIssue := cloneConfigurationState(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), true)
|
published, ownershipIssue := cloneConfigurationWithMode(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), cloneValidatedSecrets)
|
||||||
if ownershipIssue != nil {
|
if ownershipIssue != nil {
|
||||||
accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue)))
|
accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue)))
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
|
|||||||
378
projection.go
378
projection.go
@@ -1,6 +1,7 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"encoding"
|
"encoding"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -12,35 +13,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"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 {
|
|
||||||
preserveSecrets bool
|
|
||||||
schema *schemaDescriptor
|
|
||||||
limits projectionLimits
|
|
||||||
active map[valueVisit]bool
|
|
||||||
nodes int
|
|
||||||
}
|
|
||||||
|
|
||||||
func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (*document.Node, *projectionIssue) {
|
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 {
|
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"}
|
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
|
||||||
@@ -49,19 +21,6 @@ func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits
|
|||||||
return operation.project(value, schema.root, Path{}, 0)
|
return operation.project(value, schema.root, Path{}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (reflect.Value, *projectionIssue) {
|
|
||||||
return cloneConfigurationState(schema, value, limits, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publication copies preserve only already-validated private reference state.
|
|
||||||
func cloneConfigurationState(schema *schemaDescriptor, value reflect.Value, limits projectionLimits, preserveSecrets bool) (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), preserveSecrets: preserveSecrets}
|
|
||||||
return operation.clone(value, schema.root, Path{}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
if issue := o.checkDepth(path, depth); issue != nil {
|
if issue := o.checkDepth(path, depth); issue != nil {
|
||||||
return nil, issue
|
return nil, issue
|
||||||
@@ -149,7 +108,7 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
|||||||
result.Mapping = make([]document.Entry, 0, value.Len())
|
result.Mapping = make([]document.Entry, 0, value.Len())
|
||||||
keys := value.MapKeys()
|
keys := value.MapKeys()
|
||||||
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
||||||
return cmpString(left.String(), right.String())
|
return cmp.Compare(left.String(), right.String())
|
||||||
})
|
})
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
childPath := path.Key(key.String())
|
childPath := path.Key(key.String())
|
||||||
@@ -258,171 +217,6 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 o.preserveSecrets {
|
|
||||||
result.Set(value)
|
|
||||||
} else 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) {
|
func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
if issue := o.checkDepth(path, depth); issue != nil {
|
if issue := o.checkDepth(path, depth); issue != nil {
|
||||||
return nil, issue
|
return nil, issue
|
||||||
@@ -433,64 +227,6 @@ func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*docume
|
|||||||
return &document.Node{Kind: kind}, nil
|
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) {
|
func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIssue) {
|
||||||
var marshaler encoding.TextMarshaler
|
var marshaler encoding.TextMarshaler
|
||||||
if value.CanInterface() {
|
if value.CanInterface() {
|
||||||
@@ -510,113 +246,3 @@ func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIs
|
|||||||
}
|
}
|
||||||
return string(text), nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
|
|||||||
if issue != nil {
|
if issue != nil {
|
||||||
t.Fatal(issue.message)
|
t.Fatal(issue.message)
|
||||||
}
|
}
|
||||||
deltas := compareDocumentTrees(before, after)
|
deltas := compareConfigurationValues(before, after)
|
||||||
want := []struct {
|
want := []struct {
|
||||||
path string
|
path string
|
||||||
kind documentDeltaKind
|
kind documentDeltaKind
|
||||||
@@ -352,7 +352,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
|
|||||||
t.Fatal("sequence comparison created index correspondence")
|
t.Fatal("sequence comparison created index correspondence")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if equal := compareDocumentTrees(after, document.Clone(after)); len(equal) != 0 {
|
if equal := compareConfigurationValues(after, document.Clone(after)); len(equal) != 0 {
|
||||||
t.Fatalf("equal trees changed = %#v", equal)
|
t.Fatalf("equal trees changed = %#v", equal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ empty: {}
|
|||||||
t.Fatal(issue)
|
t.Fatal(issue)
|
||||||
}
|
}
|
||||||
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
|
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
|
||||||
if projectionIssue != nil || !documentNodesEqual(projected, result.final) {
|
if projectionIssue != nil || !configurationValuesEqual(projected, result.final) {
|
||||||
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
|
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
||||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||||
}
|
}
|
||||||
deltas := compareDocumentTrees(decoded.final, after)
|
deltas := compareConfigurationValues(decoded.final, after)
|
||||||
ordered := orderedResolutionRecords(state.records)
|
ordered := orderedResolutionRecords(state.records)
|
||||||
for _, delta := range deltas {
|
for _, delta := range deltas {
|
||||||
if coveringResolutionRecord(ordered, delta.path) == nil {
|
if coveringResolutionRecord(ordered, delta.path) == nil {
|
||||||
@@ -255,7 +255,7 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if record.helperExpected != nil && (!afterPresent || !documentNodesEqual(record.helperExpected, afterNode)) {
|
if record.helperExpected != nil && (!afterPresent || !configurationValuesEqual(record.helperExpected, afterNode)) {
|
||||||
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
|
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
|
||||||
}
|
}
|
||||||
_ = beforeNode
|
_ = beforeNode
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -139,7 +140,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
keys := value.MapKeys()
|
keys := value.MapKeys()
|
||||||
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmpString(left.String(), right.String()) })
|
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) })
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
entry := reflect.New(value.Type().Elem()).Elem()
|
entry := reflect.New(value.Type().Elem()).Elem()
|
||||||
entry.Set(value.MapIndex(key))
|
entry.Set(value.MapIndex(key))
|
||||||
@@ -167,7 +168,7 @@ func secretDiagnostic(code Code, path Path, message string, input *document.Node
|
|||||||
func changedSecretStates(root reflect.Value, descriptor *typeDescriptor, lookups map[string]secretLookupResult) []Path {
|
func changedSecretStates(root reflect.Value, descriptor *typeDescriptor, lookups map[string]secretLookupResult) []Path {
|
||||||
var changed []Path
|
var changed []Path
|
||||||
collectChangedSecretStates(root, descriptor, Path{}, lookups, &changed)
|
collectChangedSecretStates(root, descriptor, Path{}, lookups, &changed)
|
||||||
slices.SortFunc(changed, func(left, right Path) int { return cmpString(left.String(), right.String()) })
|
slices.SortFunc(changed, func(left, right Path) int { return cmp.Compare(left.String(), right.String()) })
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
keys := value.MapKeys()
|
keys := value.MapKeys()
|
||||||
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmpString(left.String(), right.String()) })
|
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) })
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed)
|
collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,21 +109,7 @@ func runValidation[T any](configuration *T, validator func(*T, *Validation), sch
|
|||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path))
|
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path))
|
||||||
return canonicalDiagnostics(state.diagnostics)
|
return canonicalDiagnostics(state.diagnostics)
|
||||||
}
|
}
|
||||||
for _, delta := range compareDocumentTrees(before, after) {
|
state.diagnostics = append(state.diagnostics, validationValueMutations(before, after)...)
|
||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(delta.path))
|
|
||||||
}
|
|
||||||
beforeSensitivity := projectedSensitivity(before)
|
|
||||||
afterSensitivity := projectedSensitivity(after)
|
|
||||||
for pointer, path := range beforeSensitivity {
|
|
||||||
if _, exists := afterSensitivity[pointer]; !exists {
|
|
||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for pointer, path := range afterSensitivity {
|
|
||||||
if _, exists := beforeSensitivity[pointer]; !exists {
|
|
||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) {
|
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) {
|
||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
|
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
|
||||||
}
|
}
|
||||||
@@ -178,3 +164,25 @@ func nearestDocumentLocation(root *document.Node, path Path) (document.Location,
|
|||||||
func locationKnown(location document.Location) bool {
|
func locationKnown(location document.Location) bool {
|
||||||
return location.SourceID != "" || location.Line != 0 || location.Column != 0
|
return location.SourceID != "" || location.Line != 0 || location.Column != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validationValueMutations combines value and sensitivity changes; resolved
|
||||||
|
// secret state is audited separately because it never enters projected trees.
|
||||||
|
func validationValueMutations(before, after *document.Node) []Diagnostic {
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
for _, delta := range compareConfigurationValues(before, after) {
|
||||||
|
diagnostics = append(diagnostics, validationMutationDiagnostic(delta.path))
|
||||||
|
}
|
||||||
|
beforeSensitivity := projectedSensitivity(before)
|
||||||
|
afterSensitivity := projectedSensitivity(after)
|
||||||
|
for pointer, path := range beforeSensitivity {
|
||||||
|
if _, exists := afterSensitivity[pointer]; !exists {
|
||||||
|
diagnostics = append(diagnostics, validationMutationDiagnostic(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pointer, path := range afterSensitivity {
|
||||||
|
if _, exists := beforeSensitivity[pointer]; !exists {
|
||||||
|
diagnostics = append(diagnostics, validationMutationDiagnostic(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
|||||||
126
value_operation.go
Normal file
126
value_operation.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "reflect"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueOperation owns one traversal's cycle and budget state and reuses the
|
||||||
|
// current load's descriptor cache for dynamic values.
|
||||||
|
type valueOperation struct {
|
||||||
|
cloneMode secretCloneMode
|
||||||
|
schema *schemaDescriptor
|
||||||
|
limits projectionLimits
|
||||||
|
active map[valueVisit]bool
|
||||||
|
nodes int
|
||||||
|
}
|
||||||
|
|
||||||
|
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) 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user