diff --git a/clone.go b/clone.go new file mode 100644 index 0000000..f4900b6 --- /dev/null +++ b/clone.go @@ -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 + } +} diff --git a/compose_test.go b/compose_test.go index a806eb3..00967ba 100644 --- a/compose_test.go +++ b/compose_test.go @@ -56,7 +56,7 @@ values: [one, two] if len(reversedDiagnostics) != 0 { 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) } diff --git a/composition_fuzz_test.go b/composition_fuzz_test.go index bbeb0a4..0c49a5c 100644 --- a/composition_fuzz_test.go +++ b/composition_fuzz_test.go @@ -40,7 +40,7 @@ func FuzzCompositionAndOverlay(f *testing.F) { if len(reversedDiagnostics) != 0 { 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) } composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"}) diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 551d66e..d8289c8 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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; - a per-operation private reflection descriptor graph in `schema.go` for supported types, YAML field binding, inlining, secret tags, recursion, and semantic paths; -- descriptor-driven canonical projection, detached typed cloning, cycle/limit - enforcement, and structural comparison in `projection.go`; +- descriptor-driven canonical projection in `projection.go`, detached typed + 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 mapping/sequence semantics, null and numeric policy, safe codec errors, presence, baseline/final trees, and initial default/input origins; diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index b8381af..efd7258 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -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 -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 projection, detached cloning, dynamic schema handling, cycle/limit enforcement, diff --git a/document_compare.go b/document_compare.go new file mode 100644 index 0000000..e00ced3 --- /dev/null +++ b/document_compare.go @@ -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 +} diff --git a/explain.go b/explain.go index 9b7ba51..4c9e05e 100644 --- a/explain.go +++ b/explain.go @@ -126,7 +126,7 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) { // explicitly, including canonical scalar conversions, before domain changes. replayed, _ := documentAtPath(working, path) decoded, _ := documentAtPath(r.input, path) - if !documentNodesEqual(replayed, decoded) { + if !configurationValuesEqual(replayed, decoded) { origin := cloneOrigin(r.inputOrigins[path.String()]) origin.Operation = ChangeDecoding explanation.History = append(explanation.History, ValueChange{ diff --git a/load.go b/load.go index 7450682..e211905 100644 --- a/load.go +++ b/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. // 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 { accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue))) return nil, accumulator.loadError() diff --git a/projection.go b/projection.go index e6af802..e231587 100644 --- a/projection.go +++ b/projection.go @@ -1,6 +1,7 @@ package configkit import ( + "cmp" "encoding" "math" "reflect" @@ -12,35 +13,6 @@ import ( "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) { 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"} @@ -49,19 +21,6 @@ func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits 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) { if issue := o.checkDepth(path, depth); issue != nil { return nil, issue @@ -149,7 +108,7 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor 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()) + return cmp.Compare(left.String(), right.String()) }) for _, key := range keys { 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) { if issue := o.checkDepth(path, depth); issue != nil { 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 } -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() { @@ -510,113 +246,3 @@ func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIs } 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 -} diff --git a/projection_test.go b/projection_test.go index b09778a..985177d 100644 --- a/projection_test.go +++ b/projection_test.go @@ -328,7 +328,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) { if issue != nil { t.Fatal(issue.message) } - deltas := compareDocumentTrees(before, after) + deltas := compareConfigurationValues(before, after) want := []struct { path string kind documentDeltaKind @@ -352,7 +352,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) { 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) } } diff --git a/provenance_test.go b/provenance_test.go index f6e25ea..386c09f 100644 --- a/provenance_test.go +++ b/provenance_test.go @@ -138,7 +138,7 @@ empty: {} t.Fatal(issue) } 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) } diff --git a/resolution.go b/resolution.go index 603e549..6e507a3 100644 --- a/resolution.go +++ b/resolution.go @@ -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") return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)} } - deltas := compareDocumentTrees(decoded.final, after) + deltas := compareConfigurationValues(decoded.final, after) ordered := orderedResolutionRecords(state.records) for _, delta := range deltas { 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") 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") } _ = beforeNode diff --git a/secret_reference.go b/secret_reference.go index 5b6f2c1..caee05e 100644 --- a/secret_reference.go +++ b/secret_reference.go @@ -1,6 +1,7 @@ package configkit import ( + "cmp" "encoding/json" "fmt" "io" @@ -139,7 +140,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa return } 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 { entry := reflect.New(value.Type().Elem()).Elem() 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 { var changed []Path 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 } @@ -198,7 +199,7 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, return } 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 { collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed) } diff --git a/validation.go b/validation.go index cf1bd77..ed1cca2 100644 --- a/validation.go +++ b/validation.go @@ -109,21 +109,7 @@ func runValidation[T any](configuration *T, validator func(*T, *Validation), sch state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path)) return canonicalDiagnostics(state.diagnostics) } - for _, delta := range compareDocumentTrees(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)) - } - } + state.diagnostics = append(state.diagnostics, validationValueMutations(before, after)...) for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) { 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 { 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 +} diff --git a/value_operation.go b/value_operation.go new file mode 100644 index 0000000..7e926b8 --- /dev/null +++ b/value_operation.go @@ -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 + } +}