Compare commits
31 Commits
f42ecc70c3
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a62b42503 | |||
| 244104f84f | |||
| fd8a68d268 | |||
| 8702121fa4 | |||
| 6eb40c5492 | |||
| f5bbc2be5d | |||
| b3e7b62119 | |||
| cd730d5c99 | |||
| 55fd42921a | |||
| 4bd83639a3 | |||
| 994a9ea0e2 | |||
| cb5b4356aa | |||
| 22a03c9f4d | |||
| 6a1c7df4be | |||
| 75a5829132 | |||
| 1cc645f30f | |||
| 633c5cd519 | |||
| fc2b6c81a1 | |||
| a04d32c389 | |||
| 49e04442da | |||
| 874adaaa18 | |||
| 9b147bfd96 | |||
| fb2e2c5386 | |||
| 537d26e359 | |||
| 3e70ad1285 | |||
| be2ba9a20b | |||
| edd82e15b2 | |||
| 831c83a0b5 | |||
| f7ca150a7f | |||
| 9c94f2b70a | |||
| e0f9541f5f |
@@ -33,9 +33,7 @@ func ReadConfig(path string) (Config, error) {
|
||||
The [configuration contract](docs/config.md) describes implemented behavior. The
|
||||
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
|
||||
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
|
||||
The [initial target record](docs/roadmap/initial.md) links implemented feature owners
|
||||
and preserves deliberately deferred scope. The [implementation plan](docs/roadmap/implementation.md)
|
||||
records completion evidence until release-candidate preparation retires it. See the
|
||||
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the
|
||||
[development guide](docs/development.md) for repository orientation and the
|
||||
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
||||
steps.
|
||||
|
||||
52
callback_ownership_test.go
Normal file
52
callback_ownership_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package configkit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCallbackAssignmentsAreDetachedAcrossLoads(t *testing.T) {
|
||||
type object struct{ Value string }
|
||||
type cfg struct {
|
||||
Map map[string][]*object
|
||||
Pointer *object
|
||||
Secret Secret
|
||||
}
|
||||
for _, validatorAssignment := range []bool{false, true} {
|
||||
name := "resolver"
|
||||
if validatorAssignment {
|
||||
name = "validator"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
external := &object{Value: "original"}
|
||||
shared := map[string][]*object{"key": {external}}
|
||||
definition := Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
|
||||
Normalize(r, Keys("map"), &c.Map, shared, "populate")
|
||||
c.Pointer = external
|
||||
r.Record(Keys("pointer"), ChangeNormalization, "populate")
|
||||
c.Secret = SecretEnv("CONFIGKIT_CALLBACK_SECRET")
|
||||
r.Record(Keys("secret"), ChangeNormalization, "reference")
|
||||
return nil
|
||||
}}
|
||||
if validatorAssignment {
|
||||
definition.Validate = func(c *cfg, v *Validation) { c.Map = shared; c.Pointer = external }
|
||||
}
|
||||
calls := 0
|
||||
lookup := func(string) (string, bool) { calls++; return "credential", true }
|
||||
first, err := loadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := loadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first.Config.Map["key"][0].Value = "changed"
|
||||
first.Config.Pointer.Value = "changed pointer"
|
||||
external.Value = "external change"
|
||||
if second.Config.Map["key"][0].Value != "original" || second.Config.Pointer.Value != "original" {
|
||||
t.Fatal("published configuration shares callback state")
|
||||
}
|
||||
if second.Config.Secret.Value() != "credential" || !second.Config.Secret.IsResolved() || calls != 2 {
|
||||
t.Fatal("publication changed secret state or repeated environment lookup")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
11
compose.go
11
compose.go
@@ -15,6 +15,17 @@ type composedDocument struct {
|
||||
inputNodes int
|
||||
}
|
||||
|
||||
// cloneComposedDocument detaches mutable composition and provenance state;
|
||||
// history application trees are immutable and retain their existing sharing.
|
||||
func cloneComposedDocument(base *composedDocument) *composedDocument {
|
||||
return &composedDocument{
|
||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||
applications: cloneHistoryApplications(base.applications),
|
||||
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
|
||||
}
|
||||
}
|
||||
|
||||
type compositionClaim struct {
|
||||
path Path
|
||||
kind document.Kind
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
56
decode.go
56
decode.go
@@ -34,7 +34,15 @@ type decodedConfiguration struct {
|
||||
final *document.Node
|
||||
}
|
||||
|
||||
type decodeContext struct {
|
||||
versionPath *Path
|
||||
}
|
||||
|
||||
func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input *document.Node) (decodedConfiguration, []decodeIssue) {
|
||||
return decodeConfigurationWithContext(schema, defaults, input, decodeContext{})
|
||||
}
|
||||
|
||||
func decodeConfigurationWithContext(schema *schemaDescriptor, defaults reflect.Value, input *document.Node, context decodeContext) (decodedConfiguration, []decodeIssue) {
|
||||
if schema == nil || schema.root == nil || !defaults.IsValid() || defaults.Type() != schema.root.typeOf {
|
||||
return decodedConfiguration{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "configuration defaults do not match the schema"}}
|
||||
}
|
||||
@@ -50,7 +58,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
|
||||
presence := make(map[string]inputPresence)
|
||||
if input != nil {
|
||||
collectPresence(input, Path{}, presence)
|
||||
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema)
|
||||
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema, context)
|
||||
if len(issues) != 0 {
|
||||
return decodedConfiguration{}, issues
|
||||
}
|
||||
@@ -71,7 +79,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind == document.Null {
|
||||
switch descriptor.kind {
|
||||
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
|
||||
@@ -89,7 +97,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
||||
} else {
|
||||
elementBase = base.Elem()
|
||||
}
|
||||
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema)
|
||||
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context)
|
||||
if len(issues) != 0 {
|
||||
return reflect.Value{}, issues
|
||||
}
|
||||
@@ -100,17 +108,17 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
||||
|
||||
switch descriptor.kind {
|
||||
case descriptorStruct:
|
||||
return decodeStruct(node, base, descriptor, path, schema)
|
||||
return decodeStruct(node, base, descriptor, path, schema, context)
|
||||
case descriptorMap:
|
||||
return decodeMap(node, base, descriptor, path, schema)
|
||||
return decodeMap(node, base, descriptor, path, schema, context)
|
||||
case descriptorSlice:
|
||||
return decodeSlice(node, descriptor, path, schema)
|
||||
return decodeSlice(node, descriptor, path, schema, context)
|
||||
case descriptorArray:
|
||||
return decodeArray(node, descriptor, path, schema)
|
||||
return decodeArray(node, descriptor, path, schema, context)
|
||||
case descriptorDynamic:
|
||||
return decodeDynamic(node, base, path, schema)
|
||||
return decodeDynamic(node, base, path, schema, context)
|
||||
case descriptorString:
|
||||
if node.Kind != document.String {
|
||||
if node.Kind != document.String && !(pathMatches(context.versionPath, path) && isIntegerVersion(node)) {
|
||||
return typeMismatch(path, node, "string")
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
@@ -187,7 +195,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind != document.Mapping {
|
||||
return typeMismatch(path, node, "mapping")
|
||||
}
|
||||
@@ -203,7 +211,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
||||
issues = append(issues, decodeIssue{code: CodeSchemaUnsupportedType, path: childPath, message: "configuration field could not be addressed", location: entry.ValueLocation})
|
||||
continue
|
||||
}
|
||||
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema)
|
||||
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema, context)
|
||||
if len(fieldIssues) != 0 {
|
||||
issues = append(issues, fieldIssues...)
|
||||
continue
|
||||
@@ -212,7 +220,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
||||
continue
|
||||
}
|
||||
if descriptor.inlineMap != nil {
|
||||
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema); issue != nil {
|
||||
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema, context); issue != nil {
|
||||
issues = append(issues, issue...)
|
||||
}
|
||||
continue
|
||||
@@ -222,7 +230,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor) []decodeIssue {
|
||||
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor, context decodeContext) []decodeIssue {
|
||||
mapValue, ok := fieldForDecode(target, field.index, true)
|
||||
if !ok || mapValue.Kind() != reflect.Map {
|
||||
return []decodeIssue{{code: CodeSchemaInline, path: path, message: "inline map could not be addressed", location: entry.KeyLocation}}
|
||||
@@ -236,14 +244,14 @@ func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry doc
|
||||
if !base.IsValid() {
|
||||
base = reflect.Zero(mapValue.Type().Elem())
|
||||
}
|
||||
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema)
|
||||
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema, context)
|
||||
if len(issues) == 0 {
|
||||
mapValue.SetMapIndex(key, decoded)
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind != document.Mapping {
|
||||
return typeMismatch(path, node, "mapping")
|
||||
}
|
||||
@@ -259,7 +267,7 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
|
||||
if !entryBase.IsValid() {
|
||||
entryBase = reflect.Zero(descriptor.typeOf.Elem())
|
||||
}
|
||||
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema)
|
||||
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema, context)
|
||||
if len(entryIssues) != 0 {
|
||||
issues = append(issues, entryIssues...)
|
||||
continue
|
||||
@@ -269,14 +277,14 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind != document.Sequence {
|
||||
return typeMismatch(path, node, "sequence")
|
||||
}
|
||||
result := reflect.MakeSlice(descriptor.typeOf, len(node.Sequence), len(node.Sequence))
|
||||
var issues []decodeIssue
|
||||
for i, child := range node.Sequence {
|
||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
|
||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
@@ -286,7 +294,7 @@ func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind != document.Sequence {
|
||||
return typeMismatch(path, node, "sequence")
|
||||
}
|
||||
@@ -296,7 +304,7 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
var issues []decodeIssue
|
||||
for i, child := range node.Sequence {
|
||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
|
||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
@@ -306,13 +314,13 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind == document.Mapping && base.IsValid() && !base.IsNil() {
|
||||
concrete := base.Elem()
|
||||
operation := valueOperation{schema: schema}
|
||||
descriptor, issue := operation.dynamicDescriptor(concrete.Type(), path)
|
||||
if issue == nil && acceptsMapping(descriptor) {
|
||||
decoded, issues := decodeValue(node, concrete, descriptor, path, schema)
|
||||
decoded, issues := decodeValue(node, concrete, descriptor, path, schema, context)
|
||||
if len(issues) != 0 {
|
||||
return reflect.Value{}, issues
|
||||
}
|
||||
@@ -347,7 +355,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
||||
sequence := make([]any, len(node.Sequence))
|
||||
var issues []decodeIssue
|
||||
for i, child := range node.Sequence {
|
||||
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema)
|
||||
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
@@ -362,7 +370,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
||||
mapping := make(map[string]any, len(node.Mapping))
|
||||
var issues []decodeIssue
|
||||
for _, entry := range node.Mapping {
|
||||
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema)
|
||||
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
|
||||
@@ -237,3 +237,12 @@ func escapeControls(value string) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func hasErrors(diagnostics []Diagnostic) bool {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Severity == SeverityError {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
229
directives.go
Normal file
229
directives.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
const directiveKey = "configkit"
|
||||
|
||||
type importDeclaration struct {
|
||||
path string
|
||||
glob string
|
||||
optional bool
|
||||
location Location
|
||||
}
|
||||
|
||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
|
||||
body := document.Clone(root)
|
||||
var declarations []importDeclaration
|
||||
directiveIndex := -1
|
||||
for index, entry := range body.Mapping {
|
||||
if entry.Key != directiveKey {
|
||||
continue
|
||||
}
|
||||
directiveIndex = index
|
||||
parsed, diagnostics := parseImportDirective(entry.Value, Keys(directiveKey))
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, nil, diagnostics
|
||||
}
|
||||
declarations = parsed
|
||||
break
|
||||
}
|
||||
if directiveIndex >= 0 {
|
||||
body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...)
|
||||
}
|
||||
if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 {
|
||||
return nil, nil, diagnostics
|
||||
}
|
||||
return body, declarations, nil
|
||||
}
|
||||
|
||||
func parseImportDirective(node *document.Node, directivePath Path) ([]importDeclaration, []Diagnostic) {
|
||||
if node.Kind != document.Mapping {
|
||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
|
||||
}
|
||||
var declarations []importDeclaration
|
||||
for _, entry := range node.Mapping {
|
||||
fieldPath := directivePath.Key(entry.Key)
|
||||
if entry.Key != "imports" {
|
||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value)}
|
||||
}
|
||||
if entry.Value.Kind != document.Sequence {
|
||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)}
|
||||
}
|
||||
for index, item := range entry.Value.Sequence {
|
||||
declaration, diagnostics := parseImportDeclaration(item, fieldPath.Index(index))
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
declarations = append(declarations, declaration)
|
||||
}
|
||||
}
|
||||
return declarations, nil
|
||||
}
|
||||
|
||||
func parseImportDeclaration(node *document.Node, declarationPath Path) (importDeclaration, []Diagnostic) {
|
||||
if node.Kind != document.Mapping {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
|
||||
}
|
||||
declaration := importDeclaration{location: publicLocation(node.Location)}
|
||||
seenTarget := false
|
||||
for _, entry := range node.Mapping {
|
||||
fieldPath := declarationPath.Key(entry.Key)
|
||||
switch entry.Key {
|
||||
case "path", "glob":
|
||||
if seenTarget || entry.Value.Kind != document.String || entry.Value.Text == "" || strings.TrimSpace(entry.Value.Text) != entry.Value.Text {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration requires exactly one nonempty path or glob string", entry.Value)}
|
||||
}
|
||||
seenTarget = true
|
||||
declaration.location = publicLocation(entry.Value.Location)
|
||||
if entry.Key == "path" {
|
||||
declaration.path = entry.Value.Text
|
||||
} else {
|
||||
declaration.glob = entry.Value.Text
|
||||
}
|
||||
case "optional":
|
||||
if entry.Value.Kind != document.Boolean {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
|
||||
}
|
||||
declaration.optional = entry.Value.Text == "true"
|
||||
default:
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
||||
}
|
||||
}
|
||||
if !seenTarget {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path or glob", node)}
|
||||
}
|
||||
return declaration, nil
|
||||
}
|
||||
|
||||
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||
if node.Kind == document.Mapping {
|
||||
for _, entry := range node.Mapping {
|
||||
childPath := current.Key(entry.Key)
|
||||
if entry.Key == directiveKey {
|
||||
location := publicLocation(entry.KeyLocation)
|
||||
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: childPath, Message: "configuration directives are permitted only at the document root", Primary: &location}}
|
||||
}
|
||||
if diagnostics := nestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for index, child := range node.Sequence {
|
||||
if diagnostics := nestedDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
current := root
|
||||
if len(rule.Path.segments) == 0 {
|
||||
return current
|
||||
}
|
||||
for _, segment := range rule.Path.segments {
|
||||
if current.Kind != document.Mapping {
|
||||
return current
|
||||
}
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return nil
|
||||
}
|
||||
key := segment.key
|
||||
index := documentEntryIndex(current.Mapping, key)
|
||||
if index < 0 {
|
||||
return nil
|
||||
}
|
||||
current = current.Mapping[index].Value
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func validImportPath(value string) bool {
|
||||
if value == "" || strings.TrimSpace(value) != value || path.IsAbs(value) || strings.HasPrefix(value, "//") {
|
||||
return false
|
||||
}
|
||||
return !hasImportScheme(value)
|
||||
}
|
||||
|
||||
func hasImportScheme(value string) bool {
|
||||
colon := strings.IndexByte(value, ':')
|
||||
slash := strings.IndexByte(value, '/')
|
||||
if colon <= 0 || (slash >= 0 && slash < colon) {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < colon; index++ {
|
||||
character := value[index]
|
||||
if index == 0 {
|
||||
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') &&
|
||||
(character < '0' || character > '9') && character != '+' && character != '-' && character != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func nodeImportDiagnostic(code Code, path Path, message string, node *document.Node) Diagnostic {
|
||||
location := publicLocation(node.Location)
|
||||
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
|
||||
}
|
||||
|
||||
func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||
if node.Kind == document.Mapping {
|
||||
for _, entry := range node.Mapping {
|
||||
childPath := current.Key(entry.Key)
|
||||
if entry.Key == directiveKey {
|
||||
location := publicLocation(entry.KeyLocation)
|
||||
return []Diagnostic{{
|
||||
Code: CodeProfileDirective, Severity: SeverityError, Path: childPath,
|
||||
Message: "profiles cannot contain configuration directives", Primary: &location,
|
||||
}}
|
||||
}
|
||||
if diagnostics := profileDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for index, child := range node.Sequence {
|
||||
if diagnostics := profileDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
|
||||
var diagnostics []Diagnostic
|
||||
if node.Kind == document.Mapping {
|
||||
for _, entry := range node.Mapping {
|
||||
childPath := path.Key(entry.Key)
|
||||
if entry.Key == directiveKey {
|
||||
location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column}
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Code: CodeImportDisabled, Severity: SeverityError, Path: childPath,
|
||||
Message: "configuration directives require imports to be enabled", Primary: &location,
|
||||
})
|
||||
}
|
||||
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...)
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for index, child := range node.Sequence {
|
||||
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...)
|
||||
}
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
@@ -34,7 +34,10 @@ root `T` must satisfy the application schema contract below. With no options it
|
||||
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
|
||||
runs exactly once after successful source parsing, schema construction, optional
|
||||
schema-version checking, and selected-profile loading. Its result is detached before
|
||||
input is applied.
|
||||
input is applied. Resolver-provided mutable values are detached before secret lookup
|
||||
and validation, and a final detached copy prevents equal-valued aliases introduced
|
||||
by validators from escaping into the returned configuration. The publication copy
|
||||
preserves already validated secret state without rereading the environment.
|
||||
|
||||
The current lifecycle is:
|
||||
|
||||
@@ -58,7 +61,7 @@ semantic validator, explicit path and glob imports, one selected profile, and
|
||||
filesystem context. It also accepts explicit CLI override records, static secret
|
||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
|
||||
and filesystem context as invalid combinations but accepts overrides. Input
|
||||
containing a reserved `_configkit` mapping key is rejected while imports are
|
||||
containing a reserved `configkit` mapping key is rejected while imports are
|
||||
disabled.
|
||||
|
||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||
@@ -71,9 +74,27 @@ frozen redacted projection.
|
||||
### Application schema versions
|
||||
|
||||
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
|
||||
decoding, the checker receives a `Version` preserving absent, explicit null, or
|
||||
string input. Any other input kind is a `version.type` error and the checker does not
|
||||
run. ConfigKit does not interpret the text or impose a universal version path.
|
||||
decoding, the checker receives a `Version` preserving absent, explicit null, string,
|
||||
or strict decimal-integer input. Strings and integers supply their exact spelling in
|
||||
`Version.Text`; ConfigKit does not impose a machine-integer range. Fractional or
|
||||
exponent numbers, booleans, mappings, and sequences produce `version.type`, and the
|
||||
checker does not run. ConfigKit does not interpret the text or impose a universal
|
||||
version path.
|
||||
|
||||
Integer versions use the strict grammar `^-?(0|[1-9][0-9]*)$`. Their spelling is
|
||||
preserved exactly, including `-0`, and application policy decides which identifiers
|
||||
are supported. A numeric version has no built-in `int64` range limit. Existing parser
|
||||
policy still rejects leading zeroes, leading plus signs, hexadecimal notation, and
|
||||
other non-JSON scalar forms before version checking.
|
||||
|
||||
At exactly the configured semantic path, accepted integer input can decode into a
|
||||
plain Go string or named string without a text codec, including pointer wrappers.
|
||||
This is a load-local exception: siblings and unrelated fields, dynamic values,
|
||||
integer and float destinations, durations, `Secret`, and text codecs retain ordinary
|
||||
strict decoding. The retained source and history stay numeric while typed projection
|
||||
and effective YAML contain the resulting string; explanation records this through
|
||||
the normal decoding reconciliation event. Fragment, profile, and CLI ownership
|
||||
protections apply before this conversion.
|
||||
|
||||
The checker reports safe, value-free errors and warnings through `Validation.Error`
|
||||
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
|
||||
@@ -84,7 +105,10 @@ successful result. Callback panics remain programming failures.
|
||||
|
||||
### Layered metadata
|
||||
|
||||
`Result.Metadata` describes every final canonical path. Exact surviving input paths
|
||||
`Result.Metadata` describes every final canonical path. Queries retain explicit
|
||||
key/index distinctions using actual effective and historical container shapes;
|
||||
only parsed numeric tokens are resolved contextually. A descendant that never
|
||||
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
|
||||
are explicit and have a base or profile origin; omitted paths have a default origin.
|
||||
An explicit parent mapping does not make omitted children explicit. Null state and
|
||||
available source coordinates are retained. `History` records the Go-zero or application
|
||||
@@ -93,6 +117,13 @@ equal-valued input. Profile replacement removes displaced descendants from effec
|
||||
metadata while retaining their base definition and the parent replacement operation
|
||||
in history. All metadata queries and returned nested records are detached.
|
||||
|
||||
`Metadata.HistoricalPaths` enumerates observed paths, including removed paths,
|
||||
without reloading input or resolving unselected credentials. Consumers can use
|
||||
it with `History` to validate ownership of declarations hidden by an overlay;
|
||||
domain ownership rules remain application responsibilities. The method's Go
|
||||
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
|
||||
details.
|
||||
|
||||
History is chronological: a path can have a synthetic default record, its exact base
|
||||
definition, a profile replacement, and a recorded resolution operation. A descendant
|
||||
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
||||
@@ -113,7 +144,9 @@ selected profile, then synthetic CLI records in supplied order.
|
||||
### Secret classification and effective rendering
|
||||
|
||||
Secret classification combines `configkit:"secret"` field tags with the optional
|
||||
pure `Definition.Secret(Path) bool` predicate. A tagged or predicate-classified
|
||||
pure `Definition.Secret(Path) bool` predicate. Tags on concrete structs inside
|
||||
supported dynamic values are retained in typed projections, including superseded
|
||||
defaults and pre-resolution values. A tagged or predicate-classified
|
||||
container makes all descendants sensitive. ConfigKit evaluates final and historical
|
||||
paths, so replacing or deleting a definition does not erase its classification. The
|
||||
predicate can add sensitivity but cannot declassify a tagged path.
|
||||
@@ -157,11 +190,20 @@ parser values. `Present=false` with empty YAML means absence or deletion; explic
|
||||
null is present and renders as `null`. The history starts with the canonical typed
|
||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||
traversal order, the selected profile, supplied CLI records, and audited net
|
||||
resolution operations. Explicit equal writes remain visible.
|
||||
resolution operations. Overlapping parent/child resolution records are rendered
|
||||
as one net subtree event with combined reasons and dependencies, rather than
|
||||
pretending that the recorded assignments ran in lexical order. Explicit equal
|
||||
writes remain visible. When input replay
|
||||
differs from the final typed input, a `ChangeDecoding` event records the transition
|
||||
before domain resolution. Its origin retains the decoded path's source/default
|
||||
attribution; its reason identifies static-default retention and canonical decoding.
|
||||
This built-in inspection operation is not accepted by `Resolution.Record`.
|
||||
|
||||
Base, profile, and CLI history preserves the policy-validated input representation,
|
||||
including a superseded value that could not have decoded into the final Go field.
|
||||
Baseline, effective, and resolution values use canonical typed projection. Mapping
|
||||
Baseline, effective, and resolution values use canonical typed projection. A
|
||||
decoding event bridges replayed input and canonical typed values, so intermediate
|
||||
null/scalar replacements cannot incorrectly imply permanent default removal. Mapping
|
||||
queries reconstruct each source or override contribution, while sequences remain
|
||||
atomic: a descendant history identifies its presence before and after the containing
|
||||
sequence replacement without implying element-wise merging. Removed paths remain
|
||||
@@ -226,7 +268,9 @@ public-name representation, JSON marshals only the name, and projection, metadat
|
||||
histories, diagnostics, effective output,
|
||||
and resolution snapshots never retain the credential. Validation remains read-only:
|
||||
in addition to ordinary projected-value comparison, ConfigKit checks the private
|
||||
resolved state against this load's lookup result. Applications must keep their
|
||||
resolved state against this load's lookup result. Validator mutation checks also
|
||||
reject changes to projected field sensitivity, even when a replacement dynamic
|
||||
concrete type preserves the same textual values. Applications must keep their
|
||||
validation messages value-free and must not include `Value()` in diagnostics.
|
||||
|
||||
For example, a typed pipeline field is declared as:
|
||||
@@ -400,7 +444,7 @@ origin data; history retains the displaced definitions and identifies the parent
|
||||
replacement. Surviving profile paths have profile origins, while recursively shared
|
||||
mapping containers retain their contributing locations.
|
||||
|
||||
A profile may not contain `_configkit` anywhere, contribute the configured schema
|
||||
A profile may not contain `configkit` anywhere, contribute the configured schema
|
||||
version, or replace an ancestor of that version with a scalar or null. These rules
|
||||
apply even if the supplied version text would be unchanged. Profiles cannot mask a
|
||||
base parse, composition, or version failure because those checks run first.
|
||||
@@ -433,7 +477,7 @@ All records are detached and validated before any is applied. Root and explicit
|
||||
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
|
||||
may create missing mapping ancestors but cannot traverse an existing scalar, null,
|
||||
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
||||
Neither paths nor values may create `_configkit`, and no record may overlap the
|
||||
Neither paths nor values may create `configkit`, and no record may overlap the
|
||||
configured schema-version path. Unknown fields and effective type mismatches remain
|
||||
ordinary strict-decoder errors.
|
||||
|
||||
@@ -456,12 +500,18 @@ zero.
|
||||
## Explicit imports
|
||||
|
||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||
`_configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
||||
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
|
||||
fields are `import.invalid`. A `_configkit` key below the root is invalid. With
|
||||
fields are `import.invalid`. A `configkit` key below the root is invalid. With
|
||||
imports disabled, any occurrence remains `import.disabled`.
|
||||
|
||||
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
||||
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
||||
normal schema validation, and does not trigger imports. When upgrading, rename
|
||||
directive keys in root files and imported fragments, and rename any application
|
||||
fields previously named `configkit` to avoid the newly reserved key.
|
||||
|
||||
Path declarations use slash-separated relative paths. Absolute paths and network
|
||||
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
||||
and files are traversed depth-first in declaration order: a file contributes its own
|
||||
@@ -488,7 +538,9 @@ source may supply `FilesystemContext.Root` to widen that root but may not supply
|
||||
`BaseDir`. Whenever byte and reader sources supply filesystem context, they must
|
||||
provide both `BaseDir` and `Root` as absolute existing directories; their diagnostic
|
||||
labels, including URI-looking labels, never imply filesystem paths. The base and
|
||||
selected file must be inside the canonical root. ConfigKit canonicalizes symlinks,
|
||||
selected file must be inside the canonical root. Nonregular targets are rejected before opening to avoid blocking on FIFOs;
|
||||
post-open regular-file checks remain in place under the stable-input assumption.
|
||||
ConfigKit canonicalizes symlinks,
|
||||
checks containment with relative paths, and opens the canonical root-relative target
|
||||
through a library-owned root handle. Contained relative or absolute symlink targets
|
||||
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
|
||||
@@ -570,15 +622,19 @@ Unexported fields and `yaml:"-"` are ignored. `omitempty` is accepted but does n
|
||||
change binding or projection. An explicit `yaml:",inline"` flattens a struct
|
||||
or pointer-to-struct, or designates at most one string-keyed inline map for otherwise
|
||||
unmatched names. Ambiguous names, named inline fields, multiple inline maps, and
|
||||
unsupported or malformed tag options are schema errors. Embedded fields are ordinary
|
||||
unsupported or malformed tag options are schema errors. Inline pointers must
|
||||
terminate in a struct; pointer-to-map and recursive pointer-only inline declarations
|
||||
are rejected before the defaults factory runs. Embedded fields are ordinary
|
||||
named fields unless explicitly inlined. `configkit:"secret"` is the only current
|
||||
ConfigKit field tag and marks its bound field or container for secret classification
|
||||
and redacted inspection.
|
||||
|
||||
Schema path traversal uses these same bindings. Explicit key segments address
|
||||
mappings, explicit index segments address sequences, and parsed numeric pointer
|
||||
tokens become indices only in a sequence. Sequence indices cannot have leading
|
||||
zeroes; fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
|
||||
tokens become indices only in a sequence. Sequence indices must use canonical
|
||||
nonnegative decimal spelling: no signs or leading zeroes except `0` itself.
|
||||
Tokens such as `+0` and `-0` remain literal keys in mappings but do not identify
|
||||
sequence elements. Fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
|
||||
remain schema-valid for application-defined descendants, but dynamic values cannot
|
||||
introduce typed secret references.
|
||||
|
||||
@@ -632,7 +688,11 @@ keys but preserve the strict schema of their values; inline maps accept unmatche
|
||||
keys according to their element descriptor. Multiple independent field failures are
|
||||
collected without publishing the partially decoded object.
|
||||
|
||||
Booleans and strings are not coerced. Integer destinations require integer syntax
|
||||
Booleans and strings are not generally coerced. At exactly a configured
|
||||
`VersionRule.Path`, strict decimal-integer input may decode into a plain `string`, a
|
||||
named string without a text codec, or pointer wrappers around those types. The
|
||||
exception is load-local and does not apply to unrelated fields or loads without a
|
||||
version rule. Integer destinations require integer syntax
|
||||
and a value in the destination type's range. Float destinations accept finite JSON
|
||||
numbers representable by their destination precision. Dynamic numbers become
|
||||
`int64` for integer syntax and `float64` for fractional or exponent syntax, with
|
||||
|
||||
@@ -12,7 +12,7 @@ four sanitized shapes using only ConfigKit and the Go standard library:
|
||||
| Shape | Demonstrated fit | Application code deliberately retained |
|
||||
| --- | --- | --- |
|
||||
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
|
||||
| Notarius-like | Canonical object bindings, a string schema version, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
||||
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
||||
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
|
||||
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
||||
|
||||
@@ -20,6 +20,11 @@ The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
||||
executable examples and ordinary tests. It contains no private application data,
|
||||
credentials, network calls, or dependency on a sibling checkout.
|
||||
|
||||
The Notarius-shaped fixtures deliberately load both `version: 4` and
|
||||
`version: "4"` into the same string-backed application field. Its version checker
|
||||
still owns the supported identifier; fractional numeric input and unsupported
|
||||
integer identifiers remain negative examples.
|
||||
|
||||
## Happy path
|
||||
|
||||
An application defines ordinary types and passes one explicit source. Optional
|
||||
@@ -61,8 +66,9 @@ The examples preserve domain meaning while adopting one configuration convention
|
||||
domain rule. Loaded and constructed values use the same validator.
|
||||
- Canonical module and object bindings replace string shorthand. Unknown fields and
|
||||
incompatible option shapes fail at their semantic paths.
|
||||
- Schema versions are strings. Consumers with integer versions migrate their files
|
||||
instead of enabling another scalar convention.
|
||||
- Schema versions remain string-backed in application types. A configured
|
||||
`VersionRule` accepts string or strict decimal-integer YAML at its exact path and
|
||||
supplies the spelling to both the checker and a plain string destination.
|
||||
- Integers inside dynamic options are portable `int64` values. Runtime `int`
|
||||
conversion is explicit and range checked.
|
||||
- Strict YAML intentionally rejects duplicate keys, aliases, merge keys, unsupported
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
# Development
|
||||
|
||||
This is the contributor entry point for ConfigKit. Read the task-specific owners
|
||||
below before making changes. Its public
|
||||
loader currently covers strict selected sources, explicit path/glob import graphs,
|
||||
composition, selected profile overlays, typed defaults, schema-version checks,
|
||||
explicit CLI overrides, recorded application resolution, semantic validation,
|
||||
layered metadata, secret
|
||||
classification, typed environment references, explicit secrets bootstrap, and
|
||||
redacted effective rendering and historical value explanation. The
|
||||
[component overview](internal/overview.md) owns the implementation inventory.
|
||||
Start here when contributing to ConfigKit. Read the task-specific owners below
|
||||
before making changes. The [component overview](internal/overview.md) maps
|
||||
implementation responsibilities; the [configuration contract](config.md) owns
|
||||
public behavior.
|
||||
|
||||
## Initial orientation
|
||||
|
||||
1. Inspect the working tree and preserve unrelated changes.
|
||||
2. Read the architecture policy for code or design work.
|
||||
3. Read the policy, contract, and internal documents applicable to the task.
|
||||
4. Inspect relevant implementation and tests where they exist before changing them.
|
||||
4. Inspect the relevant implementation and behavioral tests before changing them.
|
||||
|
||||
## Task-specific reading guide
|
||||
|
||||
@@ -23,23 +18,33 @@ redacted effective rendering and historical value explanation. The
|
||||
| --- | --- |
|
||||
| Repository orientation or component responsibility | [Architecture policy](policy/architecture.md) and [component overview](internal/overview.md) |
|
||||
| Documentation changes | [Documentation policy](policy/documentation.md) and the owning document |
|
||||
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [initial target record](roadmap/initial.md), and relevant ADRs listed below |
|
||||
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and owning contracts/tests as they are introduced |
|
||||
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [deferred scope](roadmap/deferred.md), and relevant ADRs listed below |
|
||||
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and the owning contract and tests from the map below |
|
||||
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
||||
| Release preparation | [Release policy](policy/release.md), [release procedure](release.md), and [release-readiness evidence](roadmap/release-readiness.md) |
|
||||
| Release preparation | [Release policy](policy/release.md) and [release procedure](release.md) |
|
||||
|
||||
Accepted decisions:
|
||||
|
||||
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
|
||||
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
|
||||
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
|
||||
|
||||
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
|
||||
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
|
||||
|
||||
For cross-cutting changes, follow every applicable row. The
|
||||
[implementation plan](roadmap/implementation.md) owns the ordered stages and their
|
||||
acceptance checks and recorded implementation evidence.
|
||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||
contracts and the component overview for ongoing work. For example:
|
||||
|
||||
```sh
|
||||
git show cb5b435:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
Release-candidate, publication, and verification work follows the operational
|
||||
[release procedure](release.md).
|
||||
|
||||
The completed string-and-integer schema-version implementation and remediation
|
||||
records are preserved in Git history at `244104f`. The
|
||||
[configuration contract](config.md#application-schema-versions) owns current behavior.
|
||||
|
||||
## Baseline validation
|
||||
|
||||
@@ -51,27 +56,51 @@ environment:
|
||||
go mod download
|
||||
```
|
||||
|
||||
Run the library baseline from the repository root:
|
||||
Run the everyday check with Bash 3.2 or newer and Git:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
git diff --check
|
||||
./scripts/check.sh
|
||||
```
|
||||
|
||||
The default tests must remain deterministic and runnable without network access or
|
||||
credentials after dependency acquisition. Also inspect new untracked files, verify
|
||||
changed local links and heading fragments, and review paths, examples, ownership,
|
||||
and current-versus-planned claims. The commands above do not check links or
|
||||
untracked content. Do not use an application binary build as the library baseline.
|
||||
Add executable-example and other checks when those artifacts are introduced; do
|
||||
not document nonexistent tools as operational.
|
||||
[The script](../scripts/check.sh) runs from the repository root regardless of the
|
||||
invocation directory. It checks Go formatting without rewriting files, checks
|
||||
staged/unstaged and untracked whitespace, then runs `go test ./...`,
|
||||
`go vet ./...`, and `go build ./...`, stopping on the first failure. It uses the
|
||||
installed Go toolchain by default, honors an explicit `GOTOOLCHAIN`, and disables
|
||||
dependency downloads. Acquire dependencies and any selected toolchain beforehand.
|
||||
Format reported Go files with `gofmt -w <files>`.
|
||||
|
||||
Once code exists, use focused checks while iterating and validation proportionate
|
||||
to the change under the testing policy. Documentation-only work does not require
|
||||
Go checks unless it changes behavior-bearing examples or commands they validate.
|
||||
Release validation is a separate, stricter gate governed by release policy.
|
||||
The default suite includes executable examples, maintained consumer fixtures,
|
||||
local documentation-link checks, and the dependency import-boundary check.
|
||||
It must stay deterministic and runnable without network access or credentials.
|
||||
Still inspect new/untracked content and review paths, examples, ownership, and
|
||||
current-versus-planned claims; automated checks do not replace content review.
|
||||
|
||||
Use focused checks while iterating, with validation proportionate to the change
|
||||
under the testing policy. Documentation-only work normally needs the local-link
|
||||
check below and whitespace review; behavior-bearing examples and commands also
|
||||
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
||||
The separate [release checker](release.md#4-validate-the-source-candidate) adds
|
||||
two-toolchain, module-hygiene, race, and cross-build gates.
|
||||
|
||||
## Task-to-test map
|
||||
|
||||
Commands run from the repository root after dependency acquisition. These are
|
||||
starting points for focused iteration; run the everyday check before handing off
|
||||
code changes. Use the component overview to find the corresponding implementation.
|
||||
|
||||
| Task | Behavioral test owners | Focused command |
|
||||
| --- | --- | --- |
|
||||
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../parse_test.go) | `go test ./internal/yaml` and `go test . -run '^TestParseSource'` |
|
||||
| File/reader access, imports, globs, containment | [Sources](../source_test.go), [FIFO](../source_fifo_test.go), [imports](../imports_test.go), [globs](../glob_test.go) | `go test . -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||
| Composition, profiles, CLI precedence | [Composition](../compose_test.go), [overlay](../overlay_test.go), [profiles](../profile_test.go), [overrides](../override_test.go) | `go test . -run 'Compose\|Overlay\|Profile\|Override'` |
|
||||
| Schema, defaults, projection, cloning | [Schema](../schema_test.go), [decoding](../decode_test.go), [projection](../projection_test.go) | `go test . -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../load_test.go), [resolution](../resolution_test.go), [validation](../validation_test.go), [ownership](../callback_ownership_test.go), [independence](../independence_test.go) | `go test . -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||
| Paths, metadata, provenance | [Paths](../path_test.go), [metadata](../metadata_test.go), [provenance](../provenance_test.go) | `go test . -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||
| Secrets, bootstrap, redaction, explanation | [References](../secret_reference_test.go), [bootstrap](../secrets_test.go), [rendering](../render_test.go), [explanation](../explain_test.go) | `go test . -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
||||
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go) | `go test . -run '^(TestLocalDocumentationLinks\|TestDependencyImportBoundary)$'` |
|
||||
| Check-command behavior | [Shell regression](../release_check_test.go) | `go test . -run '^TestCheckScripts'` |
|
||||
|
||||
## Fuzz validation
|
||||
|
||||
|
||||
@@ -115,8 +115,9 @@ Assigned to explicit import traversal and glob handling:
|
||||
Canonical paths—not `os.SameFile`—define identity, so hard links remain distinct.
|
||||
|
||||
Path-import implementation record (2026-09-06): `splitImportDirectives`,
|
||||
`parseImportDeclaration`, `importFilesystem`, `importTraversal.visitPath`, and
|
||||
`loadImportedDocuments` in `imports.go` adapt the assigned envelope, loading, source,
|
||||
`parseImportDeclaration` (now in `directives.go`), `importFilesystem` (now in
|
||||
`filesystem.go`), and `importTraversal.visitPath`/`loadImportedDocuments` in
|
||||
`imports.go` adapt the assigned envelope, loading, source,
|
||||
and failure mechanics. The corresponding ConfigKit tests adapt the assigned
|
||||
declaration-order, disjoint composition, keyed-entry, all-source conflict, unsafe
|
||||
target, alias, imported parse/schema, and relative-resolution cases. ConfigKit adds
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
# Internal Component Overview
|
||||
|
||||
ConfigKit currently contains the root `configkit` package, which establishes the
|
||||
module and package documentation boundary. Its implemented public primitives are:
|
||||
ConfigKit exposes the root `configkit` package and keeps its document and YAML
|
||||
representations private. The implemented components and their owners are:
|
||||
|
||||
- immutable semantic `Path` construction and strict JSON Pointer parsing;
|
||||
- detached provenance and diagnostic record types with empty `Metadata` queries;
|
||||
- detached effective presence/origin and chronological history queries in
|
||||
`metadata.go`, with provenance and diagnostic record types;
|
||||
- canonical, control-safe diagnostic rendering and structured `LoadError` access;
|
||||
- opaque file, byte, reader, and no-input source construction and bounded reads;
|
||||
- a parser-independent ordered tree in `internal/document`;
|
||||
- opaque file, byte, reader, and no-input source construction in `source.go`,
|
||||
with shared regular-file checks and bounded read mechanics in `source_file.go`;
|
||||
- a parser-independent ordered tree in `internal/document`, with root-private
|
||||
semantic lookup, indexing, and value traversal in `document_walk.go`;
|
||||
- the sole upstream YAML integration in `internal/yaml`, which translates validated
|
||||
input into that owned tree and exposes no parser types outside its boundary;
|
||||
`parse.go` owns shared translation into detached, ordered public diagnostics;
|
||||
- a private strict composer in `compose.go` that joins only disjoint document-tree
|
||||
definitions, retains ordered claims and composite mapping origins, reports every
|
||||
conflicting location, and never aliases an input tree;
|
||||
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
|
||||
with displacement records in `overlay.go`;
|
||||
- detached parser-neutral CLI override validation and synthetic-source application
|
||||
in `override.go`, sharing overlay, source, byte, node, and depth policies;
|
||||
with displacement records in `overlay.go`; overlay records retain origins,
|
||||
while value explanation uses separate history applications;
|
||||
- CLI override validation/preparation in `override.go`, parser-neutral values in
|
||||
`override_value.go`, and synthetic-source application in `override_apply.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;
|
||||
- public lifecycle coordination in `load.go`, bounded canonical import DFS in
|
||||
`imports.go`, pattern-directed glob expansion in `glob.go`, version-report contexts
|
||||
in `validation.go`, and detached layered metadata/history assembly in
|
||||
- public lifecycle coordination in `load.go`, option selection in `options.go`,
|
||||
input assembly in `load_input.go`, typed phases/publication in `load_result.go`,
|
||||
bounded canonical import DFS in `imports.go`, filesystem context/containment in
|
||||
`filesystem.go`, directive parsing/restrictions in `directives.go`,
|
||||
pattern-directed glob expansion in `glob.go`, schema-version checking and report
|
||||
contexts in `validation.go`, and layered metadata/history assembly in
|
||||
`load_metadata.go`;
|
||||
- recorded application defaulting and normalization in `resolution.go`, including
|
||||
explicitness queries, helper verification, structural mutation audit, coalesced
|
||||
@@ -39,15 +50,25 @@ module and package documentation boundary. Its implemented public primitives are
|
||||
- explicit bounded startup environment bootstrap and safe partial reporting in
|
||||
`secrets.go`;
|
||||
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`;
|
||||
- frozen-snapshot redaction and canonical effective output in `render.go`;
|
||||
- bounded immutable input applications, safe historical replay, detached explanation
|
||||
records, and deterministic human rendering in `explain.go`, with the
|
||||
only upstream dumper integration isolated in `internal/yaml/write.go`;
|
||||
- canonical effective output in `render.go`, using frozen-snapshot redaction
|
||||
shared with explanation in `snapshot.go`;
|
||||
- detached public explanation records and coordination in `explain.go`, immutable
|
||||
input applications/replay and retained-node budgeting in `history.go`, shared
|
||||
safe snapshot projection in `snapshot.go`, and deterministic human output in
|
||||
`explain_render.go`; the only upstream dumper integration remains isolated in
|
||||
`internal/yaml/write.go`;
|
||||
- a separate maintained `examples/consumers` package with four sanitized consumer
|
||||
shapes, fixtures, domain validators, and runtime-boundary functions; and
|
||||
- a fail-fast source-candidate checker in `scripts/release-check.sh`, with local
|
||||
- an everyday single-toolchain check in `scripts/check.sh` and a stricter
|
||||
source-candidate checker in `scripts/release-check.sh`, with local
|
||||
documentation-link and public import-boundary gates owned by root-package tests.
|
||||
|
||||
Dependency direction remains inward: the public lifecycle coordinates private
|
||||
source, document, schema, and inspection helpers. Semantic paths stay in the root
|
||||
package; `internal/document` owns only representation and cloning and never imports
|
||||
the root package. Only `internal/yaml` imports the upstream YAML package. Maintained
|
||||
consumer examples use the public package and standard library.
|
||||
|
||||
The [configuration contract](../config.md) owns implemented input, schema, decoding,
|
||||
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
|
||||
recorded resolution, typed secret references/bootstrap, semantic validation,
|
||||
@@ -62,7 +83,7 @@ implementation:
|
||||
- [Extraction inventory](extraction.md)
|
||||
|
||||
The [architecture policy](../policy/architecture.md) owns binding boundaries and
|
||||
invariants. The [initial target record](../roadmap/initial.md) links current feature
|
||||
owners and preserves deferred scope; it is not an implementation inventory. See the
|
||||
invariants. The [deferred scope](../roadmap/deferred.md) records deliberate
|
||||
exclusions. See the
|
||||
[development guide](../development.md) for contributor orientation and validation
|
||||
and the [release procedure](../release.md) for source-candidate gates.
|
||||
|
||||
@@ -10,7 +10,7 @@ ConfigKit is an opinionated Go library for consistent YAML configuration loading
|
||||
and resolution across consuming applications. This policy governs contributions.
|
||||
The [component overview](../internal/overview.md) owns implementation inventory, the
|
||||
[configuration contract](../config.md) owns implemented behavior, and the
|
||||
[initial target record](../roadmap/initial.md) preserves deferred scope.
|
||||
[deferred scope](../roadmap/deferred.md) preserves deferred scope.
|
||||
|
||||
## Success criteria
|
||||
|
||||
@@ -136,7 +136,7 @@ for safe messages supplied by their hooks.
|
||||
Do not introduce automatic directory discovery, network providers, arbitrary
|
||||
merge strategies, additional configuration languages, or application runtime
|
||||
management without concrete consumer evidence and an explicit design decision.
|
||||
The [initial target record](../roadmap/initial.md#explicit-non-goals-and-deferred-scope)
|
||||
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope)
|
||||
owns the deferred-feature inventory. Significant boundary
|
||||
changes require an ADR and corresponding policy/contract updates; implementation
|
||||
convenience alone is not a reason to broaden the public API.
|
||||
|
||||
@@ -28,8 +28,8 @@ semantics, defaults, paths, diagnostics, or compatibility guarantees.
|
||||
implementation; execution status belongs in roadmaps.
|
||||
- Roadmaps own unimplemented targets, deferred work, and execution status.
|
||||
|
||||
The initial target and implementation plan have separate owners. Do not put
|
||||
staged steps or progress in `docs/roadmap/initial.md`. `docs/roadmap/implementation.md` owns those details.
|
||||
Future implementation plans own their task order, acceptance gates, and progress;
|
||||
`docs/roadmap/deferred.md` owns deliberate exclusions, not execution instructions.
|
||||
As behavior lands, move its normative definition into the current contract and
|
||||
replace the roadmap definition with a link. Retire completed roadmap material
|
||||
as part of release preparation; ADRs preserve significant decision history.
|
||||
@@ -56,7 +56,7 @@ handling, not secret values.
|
||||
|
||||
| Topic | Owner | Boundary |
|
||||
| --- | --- | --- |
|
||||
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example once available |
|
||||
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example |
|
||||
| Contributor entry point | `docs/development.md` | Reading guide and actual baseline validation commands |
|
||||
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
|
||||
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
|
||||
@@ -64,14 +64,13 @@ handling, not secret values.
|
||||
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
|
||||
| Release procedure | `docs/release.md` | Exact maintainer commands, prerequisites, supported targets, and verification |
|
||||
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle API behavior |
|
||||
| YAML and resolution semantics | `docs/config.md`, as behavior lands | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
|
||||
| YAML and resolution semantics | `docs/config.md` | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
|
||||
| Consumer walkthroughs | `docs/consumers/`, when useful | Task-oriented usage and consumer responsibilities, not duplicate API reference |
|
||||
| Component inventory | `docs/internal/overview.md` | Implemented components and links to their owners |
|
||||
| Component mechanics | Other `docs/internal/` files, when needed | Detailed implementation behavior and change recipes |
|
||||
| Architectural decisions | `docs/adr/` | Context, decisions, alternatives, consequences, and supersession history |
|
||||
| Initial target record | `docs/roadmap/initial.md` | Implemented-feature owner links and deferred feature scope until roadmap retirement |
|
||||
| Implementation plan | `docs/roadmap/implementation.md` | Stages, dependencies, acceptance gates, and progress |
|
||||
| Release readiness evidence | `docs/roadmap/release-readiness.md` | Closed preparation gaps and outstanding candidate/publication evidence until roadmap retirement |
|
||||
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and the conditions for reconsidering them |
|
||||
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, task order, acceptance gates, and progress; completed plans retire to Git history |
|
||||
| Historical release notes | `docs/releases/` | Version-specific historical summaries required by release policy |
|
||||
| Complete examples | `examples/` and executable Go examples | Maintained artifacts; prose links instead of duplicating full examples |
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 0.1.0
|
||||
|
||||
**Last Updated:** 2026-09-06
|
||||
**Last Updated:** 2026-09-07
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -220,23 +220,18 @@ restating or redefining its requirements.
|
||||
|
||||
## Policy Exceptions
|
||||
|
||||
### Temporary bootstrap deferral
|
||||
For v0.1.1 only, the maintainer approved renaming the reserved `_configkit` key to
|
||||
`configkit` as an error-correcting point release because there are no current
|
||||
consumers. This intentionally changes the v0.1.0 configuration contract without a
|
||||
minor-version increment. The release note must explain the renamed directive,
|
||||
the lack of a legacy alias, and the newly reserved application key. This exception
|
||||
does not apply to subsequent releases or waive any validation or publication gate.
|
||||
|
||||
The release procedure and automated source-candidate checker now exist, but no exact
|
||||
release candidate, version-matched release note, native macOS execution evidence, or
|
||||
post-publication module retrieval has been validated. Initial implementation may be
|
||||
completed without fabricating those later facts. This exception does not authorize
|
||||
publication or waive any candidate, platform, remote, or consumer-verification gate.
|
||||
For v0.2.0 only, the maintainer accepted the successful native macOS check at
|
||||
`244104f` for the final candidate, provided subsequent changes are documentation
|
||||
only. The final candidate still requires the complete Linux release checker.
|
||||
This exception waives only repeating the macOS check after those documentation
|
||||
changes; it does not waive other publication or verification gates.
|
||||
|
||||
Compensating controls: the [release procedure](../release.md) keeps every unvalidated
|
||||
step as an explicit maintainer gate, and the
|
||||
[release-readiness evidence](../roadmap/release-readiness.md) records what is closed
|
||||
and outstanding. No release may be published until an exact committed candidate has
|
||||
the required release note, passes all applicable automated and native-platform gates,
|
||||
is verified against `origin/main`, and completes guarded tag publication and clean
|
||||
consumer retrieval. The maintainer preparing the first release owns closure. Remove
|
||||
this temporary exception when that evidence is recorded and obsolete roadmap files
|
||||
are retired. Any other exception requires a documented repository constraint, rationale,
|
||||
compensating controls, scope, and closure condition here; none is currently
|
||||
adopted. Do not silently weaken the base integrity, validation, documentation,
|
||||
verification, or immutability guarantees.
|
||||
Candidate selection, publication, and post-publication verification remain
|
||||
mandatory steps in the [release procedure](../release.md).
|
||||
|
||||
@@ -22,8 +22,9 @@ and macOS; cross-compilation is not native execution evidence.
|
||||
|
||||
## 1. Prepare access and tools
|
||||
|
||||
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash,
|
||||
and two supported Go toolchains available. Configure private-host Git and Go module
|
||||
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash 3.2 or newer,
|
||||
and two supported Go toolchains available. The checker supports the stock macOS
|
||||
Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module
|
||||
access through the normal credential manager before starting. Keep tokens, passwords,
|
||||
SSH material, and environment dumps outside the repository and command transcript.
|
||||
Verify access without displaying credentials:
|
||||
|
||||
72
docs/releases/v0.1.0.md
Normal file
72
docs/releases/v0.1.0.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# ConfigKit v0.1.0
|
||||
|
||||
This is the first source release of ConfigKit, an opinionated Go library for strict,
|
||||
explainable YAML configuration loading.
|
||||
|
||||
## Summary
|
||||
|
||||
ConfigKit provides one typed loading lifecycle for file, byte, reader, and
|
||||
defaults-only inputs. It supports bounded explicit imports, strict composition,
|
||||
selected profiles, explicit CLI overrides, application-owned defaults and recorded
|
||||
resolution, semantic validation, provenance, redacted effective output, and safe
|
||||
historical explanation.
|
||||
|
||||
Typed environment-name secret references and an explicit secrets-directory bootstrap
|
||||
keep credential acquisition separate from ordinary loading. Maintained consumer
|
||||
examples demonstrate simple and composed application shapes without exposing the
|
||||
private YAML parser dependency.
|
||||
|
||||
See the [configuration contract](../config.md) and
|
||||
[consumer adoption guide](../consumers/README.md) for current behavior and examples.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is the initial public release, so there is no earlier ConfigKit release to
|
||||
upgrade from. The module requires Go 1.25 or newer and supports Linux and macOS on
|
||||
amd64 and arm64. Candidate validation uses patched Go 1.25 and Go 1.26 toolchains;
|
||||
native Linux and macOS evidence is required in addition to cross-builds.
|
||||
|
||||
Configuration intentionally uses a strict YAML subset. Duplicate keys, aliases,
|
||||
anchors, merge keys, unsupported tags and scalar forms, trailing documents, and
|
||||
unknown typed fields are rejected. Composition rejects overlapping non-mapping
|
||||
definitions; replacement occurs only through selected profiles, explicit CLI
|
||||
overrides, or recorded application resolution.
|
||||
|
||||
The private parser dependency remains explicitly pinned to
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, which was still the newest available v4 release when
|
||||
the candidate was prepared. No upstream YAML declarations are part of ConfigKit's
|
||||
public API.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No action is required for an earlier ConfigKit version because this is the first
|
||||
release. New consumers can add the module with:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.0
|
||||
```
|
||||
|
||||
The authoritative host may require its normal private-module credentials. Consumers
|
||||
adapting existing application configuration should review the intentional migration
|
||||
differences in the [consumer adoption guide](../consumers/README.md), particularly
|
||||
strict YAML, string schema versions, explicit empty/null behavior, canonical module
|
||||
objects, `int64` dynamic integers, explicit CLI records, and typed secret references.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added opaque file, byte, reader, and defaults-only sources with fixed resource
|
||||
limits and safe structured diagnostics.
|
||||
- Added an owned strict YAML model, typed schema binding, detached defaults and
|
||||
results, canonical projection, and private parser isolation.
|
||||
- Added explicit path/glob imports, deterministic strict composition, canonical
|
||||
source identity and containment, and selected profile overlays.
|
||||
- Added layered presence, origins, chronological history, version checks, recorded
|
||||
defaulting and normalization, and read-only semantic validation.
|
||||
- Added secret classification, deterministic redacted effective YAML, typed
|
||||
environment references, and bounded explicit secrets-directory bootstrap reports.
|
||||
- Added parser-neutral explicit CLI overrides and safe structured/human historical
|
||||
value explanations, including removed and superseded values.
|
||||
- Added maintained Narratio-, Notarius-, WeatherReporter-, and Seriatim-shaped
|
||||
examples and migration coverage using only ConfigKit and the standard library.
|
||||
- Added race and bounded fuzz coverage plus a two-toolchain, offline, cross-platform
|
||||
source-release checker and maintainer runbook.
|
||||
43
docs/releases/v0.1.1.md
Normal file
43
docs/releases/v0.1.1.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# ConfigKit v0.1.1
|
||||
|
||||
This release corrects the spelling of ConfigKit's reserved configuration key.
|
||||
|
||||
## Summary
|
||||
|
||||
The reserved directive key is now `configkit` instead of `_configkit`. Import
|
||||
declarations continue to use the same `imports` sequence and path/glob syntax.
|
||||
See the [configuration contract](../config.md#explicit-imports).
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is an intentional configuration compatibility change, approved as a one-time
|
||||
[patch-release exception](../policy/release.md#policy-exceptions) because there are
|
||||
no current consumers. `_configkit` is no longer a directive or a reserved key; it
|
||||
is ordinary application data and is subject to normal schema validation. There is
|
||||
no legacy alias. `configkit` is now reserved, including in nested mappings, profiles,
|
||||
and override paths and values.
|
||||
|
||||
The Go API, diagnostic codes, Go minimum version, and supported platforms are
|
||||
unchanged. The private YAML dependency remains pinned to
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release
|
||||
during candidate preparation.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Rename `_configkit` directive keys to `configkit` in root configuration files and
|
||||
imported fragments. Rename any application-owned `configkit` fields to avoid the
|
||||
newly reserved key. Update diagnostic-path expectations to the new spelling.
|
||||
Files retaining `_configkit` will not load their declared imports.
|
||||
|
||||
Consumers can select this release with:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.1
|
||||
```
|
||||
|
||||
## Changes
|
||||
|
||||
- Renamed the directive key consistently across imports, profiles, and overrides.
|
||||
- Updated current configuration documentation and the composed consumer fixture.
|
||||
- Updated behavioral tests and added coverage proving the old spelling is not an
|
||||
import alias.
|
||||
50
docs/releases/v0.1.2.md
Normal file
50
docs/releases/v0.1.2.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# ConfigKit v0.1.2
|
||||
|
||||
This release adds safe enumeration of effective and removed metadata paths.
|
||||
|
||||
## Summary
|
||||
|
||||
`Metadata.HistoricalPaths` returns the semantic paths observed during a load,
|
||||
including paths removed by profiles, CLI overrides, or recorded resolution. This
|
||||
allows applications to discover hidden declarations and inspect their ownership
|
||||
through the existing `Metadata.History` API without reloading configuration or
|
||||
resolving unselected credentials.
|
||||
|
||||
See the [layered metadata contract](../config.md#layered-metadata) for current
|
||||
behavior.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is a backward-compatible API addition. Existing APIs, configuration syntax,
|
||||
diagnostic codes, minimum Go version, and supported platforms are unchanged.
|
||||
Existing consumers do not need to change their code or configuration.
|
||||
|
||||
Returned paths are detached, unique by canonical JSON Pointer spelling, and sorted
|
||||
by that spelling. The enumeration includes defaults and the root, exposes no
|
||||
configuration values, and performs no I/O or secret resolution.
|
||||
|
||||
The private YAML dependency remains pinned to
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release during
|
||||
candidate preparation.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No action required. Consumers that want historical path discovery can update with:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.2
|
||||
```
|
||||
|
||||
Then iterate over `result.Metadata().HistoricalPaths()` and use `History` for
|
||||
chronological definitions or `Lookup` to determine whether each path remains
|
||||
effective.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added `Metadata.HistoricalPaths` with detached, deterministic path enumeration.
|
||||
- Included effective, default, and removed paths while excluding paths that never
|
||||
existed.
|
||||
- Added coverage for parent removal, mapping-to-sequence shape replacement, escaped
|
||||
mapping keys, detached results, zero metadata, composite base ownership, and
|
||||
avoidance of superseded secret lookup.
|
||||
- Documented historical path enumeration in the current configuration contract.
|
||||
58
docs/releases/v0.2.0.md
Normal file
58
docs/releases/v0.2.0.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# ConfigKit v0.2.0
|
||||
|
||||
This release adds integer schema-version input and consistent canonical sequence
|
||||
path handling.
|
||||
|
||||
## Summary
|
||||
|
||||
Applications can retain YAML such as `version: 4` while storing and checking their
|
||||
version as a Go string. The exception applies only to the path designated by
|
||||
`VersionRule`; unrelated fields keep strict decoding. See the
|
||||
[configuration contract](../config.md#application-schema-versions).
|
||||
|
||||
## Compatibility
|
||||
|
||||
This minor release intentionally changes integer schema-version input from a
|
||||
`version.type` rejection to invoking the application checker with its decimal text.
|
||||
Plain string destinations at that path now accept it. Public Go API shapes,
|
||||
minimum Go version, and supported platforms are unchanged.
|
||||
|
||||
Parsed sequence indices now require canonical nonnegative decimal spelling.
|
||||
Signed tokens such as `+0` and `-0`, previously accepted by some traversal paths,
|
||||
no longer identify sequence elements. They remain valid literal mapping keys.
|
||||
Version checking, decoding, and metadata matching now agree on this boundary.
|
||||
|
||||
The private YAML dependency remains pinned to `go.yaml.in/yaml/v4 v4.0.0-rc.6`,
|
||||
confirmed as the newest available v4 release during candidate preparation. Linux
|
||||
candidate checks use Go 1.25.5 and Go 1.26.7. The maintainer confirmed native macOS
|
||||
checks at `244104f` and approved carrying that result across the documentation-only
|
||||
candidate changes under the [recorded exception](../policy/release.md#policy-exceptions).
|
||||
|
||||
## Upgrade
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.2.0
|
||||
```
|
||||
|
||||
Existing string schema versions need no configuration changes. Applications using
|
||||
integer YAML versions can retain them and use a plain string field plus a
|
||||
`VersionRule`. Review the application checker: it now receives integer identifiers
|
||||
as text, and remains responsible for rejecting unsupported versions. Fractions,
|
||||
exponents, booleans, and containers remain invalid version kinds.
|
||||
|
||||
Replace signed sequence-index tokens in configured paths with canonical indices
|
||||
(for example, `/versions/+0` becomes `/versions/0`). Do not change signed mapping
|
||||
keys. Literal integers elsewhere still do not decode into strings, and numeric
|
||||
destinations retain their existing range checks.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added load-local, path-scoped integer-to-string schema-version decoding without
|
||||
mutating original input or introducing general scalar coercion.
|
||||
- Preserved numeric historical input, string effective output, decoding
|
||||
reconciliation, source attribution, and secret redaction.
|
||||
- Unified sequence path interpretation and added regression coverage for signed
|
||||
tokens, literal mapping keys, and explicit key/index distinctions.
|
||||
- Updated maintained Notarius-shaped fixtures and migration guidance for quoted
|
||||
and integer version forms.
|
||||
- Retired completed implementation and remediation roadmaps to Git history.
|
||||
28
docs/roadmap/deferred.md
Normal file
28
docs/roadmap/deferred.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Deferred Scope
|
||||
|
||||
This document owns intentionally excluded features, not a committed delivery plan.
|
||||
The [architecture policy](../policy/architecture.md) governs changes to these
|
||||
boundaries. Implemented behavior belongs in the [configuration contract](../config.md)
|
||||
and consumer adoption in the [consumer guide](../consumers/README.md).
|
||||
|
||||
## Explicit non-goals and deferred scope
|
||||
|
||||
General environment-variable configuration overlays are not supported. Typed secret
|
||||
references and explicit directory bootstrap are the environment convention. Literal
|
||||
credential configuration and implicit secret-directory discovery are unsupported.
|
||||
|
||||
The initial release deliberately excludes:
|
||||
|
||||
- automatic `conf.d` discovery;
|
||||
- remote imports, arbitrary providers, and additional configuration formats;
|
||||
- custom merge strategies, sequence merging, reset operators, and profile inheritance;
|
||||
- parser-specific or custom structural-decoding extension APIs;
|
||||
- generic dynamic-subtree conversion and module-registry APIs;
|
||||
- automatic schema migrations;
|
||||
- hot reload, runtime resource replacement, and remote resource validation;
|
||||
- a bundled CLI framework or application commands; and
|
||||
- configuration diffs and fingerprints.
|
||||
|
||||
Applications may build their own validate, effective-value, source, bootstrap, and
|
||||
explanation commands around the library. Broadening these boundaries requires
|
||||
concrete consumer evidence, compatibility review, and an explicit design change.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,83 +0,0 @@
|
||||
# ConfigKit Initial Target Record
|
||||
|
||||
## Status and ownership
|
||||
|
||||
The initial feature target is implemented. This document now records the intended
|
||||
outcome, source foundations, and deliberately deferred scope without duplicating the
|
||||
current configuration contract or Go API reference. Ordered implementation history
|
||||
and acceptance evidence remain in the [implementation plan](implementation.md) until
|
||||
the maintainer retires completed roadmaps while preparing a release candidate.
|
||||
|
||||
The [architecture policy](../policy/architecture.md) owns responsibilities,
|
||||
dependency direction, confidentiality, and the two success criteria. The
|
||||
[configuration contract](../config.md) and Go declarations own implemented behavior.
|
||||
The [consumer adoption guide](../consumers/README.md) owns consumer fit and migration
|
||||
guidance. Accepted rationale remains in the [ADRs](../development.md#task-specific-reading-guide).
|
||||
|
||||
## Initial outcome
|
||||
|
||||
ConfigKit is one opinionated Go library for strict YAML configuration loading. It
|
||||
removes source handling, parsing, explicit import/profile traversal, composition,
|
||||
precedence, presence tracking, provenance, secret lookup/bootstrap, diagnostics, and
|
||||
safe inspection plumbing from applications. Applications retain their types,
|
||||
defaults, schema-version policy, recorded normalization, semantic validation, CLI
|
||||
parsing, remote acquisition, domain algorithms, resource checks, and runtime setup.
|
||||
|
||||
The maintained Narratio-, Notarius-, WeatherReporter-, and Seriatim-shaped examples
|
||||
demonstrate both architecture success criteria: simple consumers avoid optional
|
||||
composition machinery, complex consumers use the same conventions, and domain logic
|
||||
remains recognizable application code. These examples are migration evidence, not a
|
||||
claim that any sibling application has migrated.
|
||||
|
||||
## Implementation foundations
|
||||
|
||||
The implementation was generalized from maintainer-authorized application code and
|
||||
tests at fixed clean revisions. The [extraction inventory](../internal/extraction.md)
|
||||
records exact revisions, symbols, adapted cases, intentional semantic differences,
|
||||
and the boundary that keeps sibling checkouts out of builds and tests. Links from
|
||||
that inventory or historical implementation evidence to sibling source are optional
|
||||
in isolated clones; immutable revision references remain authoritative evidence.
|
||||
|
||||
The module uses `go.yaml.in/yaml/v4` only behind `internal/yaml`. ConfigKit owns the
|
||||
document model, field binding, scalar grammar, errors, limits, and output behavior.
|
||||
No upstream type, option, plugin, error chain, hidden parser value, or generic
|
||||
constraint crosses the public API. The package-level import-boundary test enforces
|
||||
this isolation for production, tests, and maintained examples.
|
||||
|
||||
## Current feature owners
|
||||
|
||||
| Initial feature area | Current owner |
|
||||
| --- | --- |
|
||||
| Sources, lifecycle, limits, YAML/scalars, decoding, and ownership | [Configuration contract](../config.md) and Go reference comments |
|
||||
| Paths, presence, origins, sources, and definition history | [Layered metadata](../config.md#layered-metadata), [`path.go`](../../path.go), and [`metadata.go`](../../metadata.go) |
|
||||
| Strict composition, imports, globs, source identity, and profiles | [Strict composition](../config.md#strict-document-composition), [explicit imports](../config.md#explicit-imports), and [selected profiles](../config.md#selected-profiles) |
|
||||
| Static defaults, null, types, projection, and custom text scalars | [Defaults and typed decoding](../config.md#defaults-and-typed-decoding), [schema binding](../config.md#application-schema-binding), and [typed projection](../config.md#typed-projection-and-ownership) |
|
||||
| Schema versions, recorded resolution, and semantic validation | [Version contract](../config.md#application-schema-versions), [resolution](../config.md#recorded-application-resolution), and [validation](../config.md#semantic-validation) |
|
||||
| Secret classification, typed references, and directory bootstrap | [Redaction](../config.md#secret-classification-and-effective-rendering), [references](../config.md#typed-secret-references), and [bootstrap](../config.md#explicit-secrets-directory-bootstrap) |
|
||||
| Explicit CLI overrides and complete precedence | [CLI override contract](../config.md#explicit-cli-overrides) |
|
||||
| Effective rendering and safe historical explanation | [Effective rendering](../config.md#secret-classification-and-effective-rendering) and [historical explanation](../config.md#historical-value-explanation) |
|
||||
| Canonical module options and downstream adoption | [Module options](../config.md#canonical-module-options) and [consumer guide](../consumers/README.md) |
|
||||
| Diagnostics and structured errors | [`records.go`](../../records.go), [`diagnostic.go`](../../diagnostic.go), and relevant configuration-contract sections |
|
||||
| Internal components and dependency placement | [Component overview](../internal/overview.md) and [architecture policy](../policy/architecture.md) |
|
||||
|
||||
## Explicit non-goals and deferred scope
|
||||
|
||||
General environment-variable configuration overlays are not supported. Typed secret
|
||||
references and explicit directory bootstrap are the environment convention. Literal
|
||||
credential configuration and implicit secret-directory discovery are unsupported.
|
||||
|
||||
The initial release deliberately excludes:
|
||||
|
||||
- automatic `conf.d` discovery;
|
||||
- remote imports, arbitrary providers, and additional configuration formats;
|
||||
- custom merge strategies, sequence merging, reset operators, and profile inheritance;
|
||||
- parser-specific or custom structural-decoding extension APIs;
|
||||
- generic dynamic-subtree conversion and module-registry APIs;
|
||||
- automatic schema migrations;
|
||||
- hot reload, runtime resource replacement, and remote resource validation;
|
||||
- a bundled CLI framework or application commands; and
|
||||
- configuration diffs and fingerprints.
|
||||
|
||||
Applications may build their own validate, effective-value, source, bootstrap, and
|
||||
explanation commands around the library. Broadening these boundaries requires
|
||||
concrete consumer evidence, compatibility review, and an explicit design change.
|
||||
@@ -1,37 +0,0 @@
|
||||
# Release Readiness
|
||||
|
||||
## Status and ownership
|
||||
|
||||
ConfigKit's implementation is prepared for release-candidate validation, but no
|
||||
release candidate has been selected or publication authorized. The maintainer
|
||||
preparing the first release owns the remaining gates under the
|
||||
[temporary bootstrap exception](../policy/release.md#temporary-bootstrap-deferral).
|
||||
This evidence record remains until candidate preparation retires completed roadmap
|
||||
material in favor of the operational [release procedure](../release.md) and normal
|
||||
release notes.
|
||||
|
||||
## Preparation evidence
|
||||
|
||||
| Preparation item | Status and evidence |
|
||||
| --- | --- |
|
||||
| Module and support matrix | Closed: the runbook records module `gitea.maximumdirect.net/eric/ConfigKit`, minimum Go 1.25 with a patched 1.25 plus newer supported toolchain, and Linux/macOS on amd64/arm64. |
|
||||
| Maintainer release procedure | Closed: `docs/release.md` owns access prerequisites, candidate recording, complete validation, exact remote/commit/tag guards, source-only publication, consumer retrieval, and correction. |
|
||||
| Automated source checker | Closed for current-source preparation: `scripts/release-check.sh` passed on 2026-09-06 with Go 1.25.5 and Go 1.26.7 on linux/amd64. It performs disposable-copy module hygiene, cached/offline tests, race, vet, native and four-target cross-builds, formatting/whitespace, examples, import isolation, and local-link gates. The exact candidate must rerun it. |
|
||||
| Consumer verification procedure | Closed as procedure: the runbook retrieves an exact published version into a clean temporary module with no local replacement and tests/builds it. Actual retrieval remains a post-publication gate. |
|
||||
| Secret/precedence/explanation evidence | Closed locally: current contracts and deterministic tests cover bootstrap skip/partial failure, credential isolation, defaults/base/profile/CLI precedence, historical redaction, and maintained consumer startup examples. |
|
||||
| Parser dependency reassessment | Closed for preparation: on 2026-09-06, `go list -m -versions go.yaml.in/yaml/v4` and `go list -m -json go.yaml.in/yaml/v4@latest` both showed `v4.0.0-rc.6` as the newest available release. The explicit pin is retained; a candidate compatibility review must reassess and record it again. |
|
||||
|
||||
## Outstanding release gates
|
||||
|
||||
| Gate | Owner and closure evidence |
|
||||
| --- | --- |
|
||||
| Exact candidate and release note | Maintainer selects an unused stable version, writes `docs/releases/<version>.md`, commits all release changes, runs the checker against that exact clean commit, pushes it to `origin/main`, and records the commit. |
|
||||
| Native platform execution | Maintainer records applicable checker, race, symlink, and permission-fixture results on native Linux and macOS. Current local evidence is Linux/amd64 only; cross-builds do not close macOS execution. |
|
||||
| Compatibility, migration, and content review | Maintainer records public API/configuration/diagnostic review, downstream migration impact, release-note approval, and inspection for credentials, private data, generated output, unintended files, and unrelated changes. |
|
||||
| Roadmap retirement | Maintainer removes obsolete completed files under `docs/roadmap/`, repairs inbound links, and reruns the checker as part of the candidate. This record and the implementation plan intentionally remain while no candidate exists. |
|
||||
| Remote publication guards | Maintainer fetches authoritative `origin/main` and tags immediately before publication and proves branch, clean worktree/index, candidate commit, remote commit, release note, approvals, and unused version all agree. |
|
||||
| Tag and retrieval verification | After authorization, maintainer pushes only the exact immutable source tag, verifies its peeled remote commit, and retrieves/tests/builds the exact version in a clean consumer module without `replace`. |
|
||||
|
||||
No tag, push, downstream migration, release note, or release publication was
|
||||
performed during implementation. A green local checker does not close the exact
|
||||
candidate, native macOS, remote, or post-publication gates.
|
||||
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
|
||||
}
|
||||
86
document_walk.go
Normal file
86
document_walk.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package configkit
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
// walkDocument visits value nodes in preorder, preserving stored mapping order
|
||||
// and sequence index order. Mapping keys are not separate nodes in this walk.
|
||||
// The callback borrows paths and nodes; clone any mutable state it retains.
|
||||
// Specialized origin, pruning, and budget traversals retain their own semantics.
|
||||
func walkDocument(node *document.Node, path Path, visit func(Path, *document.Node)) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
visit(path, node)
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
for _, entry := range node.Mapping {
|
||||
walkDocument(entry.Value, path.Key(entry.Key), visit)
|
||||
}
|
||||
case document.Sequence:
|
||||
for index, child := range node.Sequence {
|
||||
walkDocument(child, path.Index(index), visit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// indexDocument borrows nodes from its caller-owned tree.
|
||||
func indexDocument(node *document.Node, path Path, index map[string]*document.Node) {
|
||||
walkDocument(node, path, func(path Path, node *document.Node) {
|
||||
index[path.String()] = node
|
||||
})
|
||||
}
|
||||
|
||||
func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
|
||||
if root == nil || !path.valid() {
|
||||
return nil, false
|
||||
}
|
||||
current := root
|
||||
for _, segment := range path.segments {
|
||||
switch current.Kind {
|
||||
case document.Mapping:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return nil, false
|
||||
}
|
||||
found := false
|
||||
for _, entry := range current.Mapping {
|
||||
if entry.Key == segment.key {
|
||||
current = entry.Value
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
case document.Sequence:
|
||||
index, ok := schemaIndex(segment)
|
||||
if !ok || index >= len(current.Sequence) {
|
||||
return nil, false
|
||||
}
|
||||
current = current.Sequence[index]
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
// countInputNodes includes mapping-key nodes, unlike walkDocument's value-only
|
||||
// traversal. Aggregate source budgets charge every input occurrence.
|
||||
func countInputNodes(node *document.Node) int {
|
||||
if node == nil {
|
||||
return 0
|
||||
}
|
||||
count := 1
|
||||
if node.Kind == document.Mapping {
|
||||
count += len(node.Mapping)
|
||||
for _, entry := range node.Mapping {
|
||||
count += countInputNodes(entry.Value)
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for _, child := range node.Sequence {
|
||||
count += countInputNodes(child)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -83,7 +83,7 @@ type ObjectBinding struct {
|
||||
Options map[string]any `yaml:"options"`
|
||||
}
|
||||
|
||||
// NotariusConfig demonstrates string versions and dynamic catalog options.
|
||||
// NotariusConfig demonstrates string-backed versions and dynamic catalog options.
|
||||
type NotariusConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
Bindings []ObjectBinding `yaml:"bindings"`
|
||||
@@ -93,7 +93,7 @@ type NotariusConfig struct {
|
||||
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
|
||||
return configkit.Load(configkit.File(path), configkit.Options[NotariusConfig]{
|
||||
Definition: configkit.Definition[NotariusConfig]{
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("v1")},
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("4")},
|
||||
Validate: validateNotarius,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ func Example_notarius() {
|
||||
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
||||
window, ok := consumers.NotariusWindow(result.Config, 0)
|
||||
fmt.Println(result.Config.Version, result.Config.Bindings[0].Object, window, ok, err)
|
||||
// Output: v1 summary 12 true <nil>
|
||||
// Output: 4 summary 12 true <nil>
|
||||
}
|
||||
|
||||
func Example_weatherReporter() {
|
||||
@@ -100,6 +100,10 @@ func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
|
||||
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
|
||||
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
|
||||
}
|
||||
integerNotarius, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml"))
|
||||
if err != nil || integerNotarius.Config.Version != notarius.Config.Version || integerNotarius.Config.Version != "4" {
|
||||
t.Fatalf("Notarius version forms = %#v / %#v, %v", notarius.Config, integerNotarius, err)
|
||||
}
|
||||
|
||||
weather, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
|
||||
if err != nil || weather.Config.Reports["daily"].Modules[1].Options.Sections[0] != "short_term" {
|
||||
@@ -124,8 +128,19 @@ func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "integer schema version",
|
||||
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
|
||||
name: "unsupported application version",
|
||||
load: func() error {
|
||||
_, err := consumers.LoadNotarius(fixture("notarius", "unsupported-version.yml"))
|
||||
return err
|
||||
},
|
||||
code: configkit.CodeVersionError, path: "/version",
|
||||
},
|
||||
{
|
||||
name: "non-integer numeric version",
|
||||
load: func() error {
|
||||
_, err := consumers.LoadNotarius(fixture("notarius", "noninteger-version.yml"))
|
||||
return err
|
||||
},
|
||||
code: configkit.CodeVersionType, path: "/version",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
_configkit:
|
||||
configkit:
|
||||
imports:
|
||||
- path: artifacts.yml
|
||||
poll_interval: 1m
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: v1
|
||||
version: "4"
|
||||
bindings:
|
||||
- object: summary
|
||||
options:
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
version: 1
|
||||
bindings: []
|
||||
version: 4
|
||||
bindings:
|
||||
- object: summary
|
||||
options:
|
||||
window_units: 12
|
||||
format: markdown
|
||||
|
||||
2
examples/consumers/testdata/notarius/noninteger-version.yml
vendored
Normal file
2
examples/consumers/testdata/notarius/noninteger-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
version: 4.0
|
||||
bindings: []
|
||||
2
examples/consumers/testdata/notarius/unsupported-version.yml
vendored
Normal file
2
examples/consumers/testdata/notarius/unsupported-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
version: 5
|
||||
bindings: []
|
||||
443
explain.go
443
explain.go
@@ -1,23 +1,6 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||
)
|
||||
|
||||
const historyNodeLimit = 4_000_000
|
||||
|
||||
var (
|
||||
errExplanationUnavailable = errors.New("configkit: configuration explanation is unavailable")
|
||||
errExplanationUnknownPath = errors.New("configkit: configuration explanation path is unknown")
|
||||
)
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
// ValueSnapshot is a detached, deterministic YAML representation of one value.
|
||||
// Present distinguishes absence or deletion from an explicit null value.
|
||||
@@ -51,42 +34,10 @@ type Explanation struct {
|
||||
SecretReference *SecretReferenceInfo
|
||||
}
|
||||
|
||||
type historyApplication struct {
|
||||
layer Layer
|
||||
path Path
|
||||
value *document.Node
|
||||
origin Origin
|
||||
}
|
||||
|
||||
func cloneHistoryApplications(values []historyApplication) []historyApplication {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]historyApplication, len(values))
|
||||
for index, value := range values {
|
||||
cloned[index] = historyApplication{
|
||||
layer: value.layer, path: value.path.clone(), value: value.value,
|
||||
origin: cloneOrigin(value.origin),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneSecretReferences(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]string, len(values))
|
||||
for path, name := range values {
|
||||
cloned[path] = name
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// Explain returns safe effective and chronological values for a known final or
|
||||
// historical path. Unknown and malformed paths return false.
|
||||
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||
if r == nil || r.final == nil || r.baseline == nil || !path.valid() {
|
||||
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
|
||||
return Explanation{}, false
|
||||
}
|
||||
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
|
||||
@@ -110,15 +61,35 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
after := valueSnapshot(working, path, r.secrets)
|
||||
if !before.Present && !after.Present {
|
||||
continue
|
||||
}
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: before,
|
||||
After: valueSnapshot(working, path, r.secrets),
|
||||
After: after,
|
||||
Origin: historyApplicationOrigin(application, operationPath),
|
||||
})
|
||||
}
|
||||
|
||||
// Intermediate input replacements do not permanently suppress defaults:
|
||||
// decoding applies them to the final input tree. Show that reconciliation
|
||||
// explicitly, including canonical scalar conversions, before domain changes.
|
||||
replayed, _ := documentAtPath(working, path)
|
||||
decoded, _ := documentAtPath(r.input, path)
|
||||
if !configurationValuesEqual(replayed, decoded) {
|
||||
origin := cloneOrigin(r.inputOrigins[path.String()])
|
||||
origin.Operation = ChangeDecoding
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: valueSnapshot(working, path, r.secrets),
|
||||
After: valueSnapshot(r.input, path, r.secrets),
|
||||
Origin: origin,
|
||||
Reasons: []string{"applied retained defaults and canonical typed decoding"},
|
||||
})
|
||||
}
|
||||
|
||||
working = document.Clone(r.input)
|
||||
for _, snapshot := range r.resolution {
|
||||
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
|
||||
relevant := pathsIntersect(snapshot.change.Path, path)
|
||||
var before ValueSnapshot
|
||||
if relevant {
|
||||
@@ -128,9 +99,13 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||
if !relevant {
|
||||
continue
|
||||
}
|
||||
after := valueSnapshot(working, path, r.secrets)
|
||||
if !before.Present && !after.Present {
|
||||
continue
|
||||
}
|
||||
explanation.History = append(explanation.History, ValueChange{
|
||||
Before: before,
|
||||
After: valueSnapshot(working, path, r.secrets),
|
||||
After: after,
|
||||
Origin: cloneOrigin(snapshot.change.Origin),
|
||||
Reasons: append([]string(nil), snapshot.change.Reasons...),
|
||||
Dependencies: clonePaths(snapshot.change.Dependencies),
|
||||
@@ -145,363 +120,3 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||
}
|
||||
return explanation, true
|
||||
}
|
||||
|
||||
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
|
||||
if !application.path.valid() || application.value == nil {
|
||||
return Path{}, false
|
||||
}
|
||||
if application.path.String() != "" {
|
||||
if pathsIntersect(application.path, query) {
|
||||
return application.path.clone(), true
|
||||
}
|
||||
return Path{}, false
|
||||
}
|
||||
if len(query.segments) == 0 {
|
||||
return Path{}, true
|
||||
}
|
||||
for length := 1; length <= len(query.segments); length++ {
|
||||
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
||||
node, present := documentAtPath(application.value, prefix)
|
||||
if !present {
|
||||
return Path{}, false
|
||||
}
|
||||
if length == len(query.segments) || node.Kind != document.Mapping {
|
||||
return prefix, true
|
||||
}
|
||||
}
|
||||
return Path{}, false
|
||||
}
|
||||
|
||||
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
|
||||
if application.origin.Layer != "" {
|
||||
return cloneOrigin(application.origin)
|
||||
}
|
||||
origin := Origin{Layer: application.layer}
|
||||
relative, ok := relativeHistoryPath(application.path, operationPath)
|
||||
if !ok {
|
||||
return origin
|
||||
}
|
||||
if node, present := documentAtPath(application.value, relative); present {
|
||||
origin.Locations = []Location{publicLocation(node.Location)}
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
func relativeHistoryPath(root, path Path) (Path, bool) {
|
||||
if !pathContains(root, path) {
|
||||
return Path{}, false
|
||||
}
|
||||
return Path{segments: clonePathSegments(path.segments[len(root.segments):], 0)}, true
|
||||
}
|
||||
|
||||
func pathsIntersect(left, right Path) bool {
|
||||
return pathContains(left, right) || pathContains(right, left)
|
||||
}
|
||||
|
||||
func applyHistoryApplication(root **document.Node, application historyApplication) {
|
||||
if application.path.String() == "" {
|
||||
mergeHistoryDocument(root, application.value)
|
||||
return
|
||||
}
|
||||
mergeHistoryAtPath(root, application.path, application.value)
|
||||
}
|
||||
|
||||
func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
|
||||
if incoming == nil {
|
||||
return
|
||||
}
|
||||
if *target == nil || (*target).Kind != document.Mapping || incoming.Kind != document.Mapping {
|
||||
*target = document.Clone(incoming)
|
||||
return
|
||||
}
|
||||
for _, entry := range incoming.Mapping {
|
||||
position := documentEntryIndex((*target).Mapping, entry.Key)
|
||||
if position < 0 {
|
||||
cloned := entry
|
||||
cloned.Value = document.Clone(entry.Value)
|
||||
(*target).Mapping = append((*target).Mapping, cloned)
|
||||
continue
|
||||
}
|
||||
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) {
|
||||
if root == nil || *root == nil || len(path.segments) == 0 {
|
||||
mergeHistoryDocument(root, incoming)
|
||||
return
|
||||
}
|
||||
current := *root
|
||||
for index, segment := range path.segments {
|
||||
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
|
||||
return
|
||||
}
|
||||
position := documentEntryIndex(current.Mapping, segment.key)
|
||||
if index == len(path.segments)-1 {
|
||||
if position < 0 {
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
|
||||
} else {
|
||||
mergeHistoryDocument(¤t.Mapping[position].Value, incoming)
|
||||
}
|
||||
return
|
||||
}
|
||||
if position < 0 {
|
||||
child := &document.Node{Kind: document.Mapping}
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
||||
current = child
|
||||
} else {
|
||||
current = current.Mapping[position].Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) {
|
||||
if root == nil || *root == nil {
|
||||
return
|
||||
}
|
||||
if len(path.segments) == 0 {
|
||||
*root = document.Clone(replacement)
|
||||
return
|
||||
}
|
||||
current := *root
|
||||
for index, segment := range path.segments {
|
||||
last := index == len(path.segments)-1
|
||||
switch current.Kind {
|
||||
case document.Mapping:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return
|
||||
}
|
||||
position := documentEntryIndex(current.Mapping, segment.key)
|
||||
if position < 0 {
|
||||
return
|
||||
}
|
||||
if last {
|
||||
if replacement == nil {
|
||||
current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...)
|
||||
} else {
|
||||
current.Mapping[position].Value = document.Clone(replacement)
|
||||
}
|
||||
return
|
||||
}
|
||||
current = current.Mapping[position].Value
|
||||
case document.Sequence:
|
||||
position, ok := schemaIndex(segment)
|
||||
if !ok || position >= len(current.Sequence) {
|
||||
return
|
||||
}
|
||||
if last {
|
||||
if replacement != nil {
|
||||
current.Sequence[position] = document.Clone(replacement)
|
||||
}
|
||||
return
|
||||
}
|
||||
current = current.Sequence[position]
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot {
|
||||
node, present := documentAtPath(root, path)
|
||||
if !present {
|
||||
return ValueSnapshot{}
|
||||
}
|
||||
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
||||
var output bytes.Buffer
|
||||
if err := yamladapter.Write(&output, safe); err != nil {
|
||||
return ValueSnapshot{Present: true, Redacted: true, YAML: "'[REDACTED]'\n"}
|
||||
}
|
||||
return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()}
|
||||
}
|
||||
|
||||
func safeSnapshotDocument(node *document.Node, secrets map[string]bool, path Path) (*document.Node, bool) {
|
||||
if node == nil {
|
||||
return nil, false
|
||||
}
|
||||
if pathIsSecret(secrets, path) {
|
||||
return &document.Node{Kind: document.String, Text: "[REDACTED]"}, true
|
||||
}
|
||||
result := *node
|
||||
redacted := false
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
result.Mapping = make([]document.Entry, len(node.Mapping))
|
||||
copy(result.Mapping, node.Mapping)
|
||||
sortDocumentMapping(result.Mapping)
|
||||
for index := range result.Mapping {
|
||||
entry := &result.Mapping[index]
|
||||
entry.Value, redacted = safeSnapshotChild(entry.Value, secrets, path.Key(entry.Key), redacted)
|
||||
}
|
||||
case document.Sequence:
|
||||
result.Sequence = make([]*document.Node, len(node.Sequence))
|
||||
for index, child := range node.Sequence {
|
||||
result.Sequence[index], redacted = safeSnapshotChild(child, secrets, path.Index(index), redacted)
|
||||
}
|
||||
}
|
||||
return &result, redacted
|
||||
}
|
||||
|
||||
func safeSnapshotChild(node *document.Node, secrets map[string]bool, path Path, prior bool) (*document.Node, bool) {
|
||||
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
||||
return safe, prior || redacted
|
||||
}
|
||||
|
||||
func sortDocumentMapping(entries []document.Entry) {
|
||||
slices.SortFunc(entries, func(left, right document.Entry) int {
|
||||
return strings.Compare(left.Key, right.Key)
|
||||
})
|
||||
}
|
||||
|
||||
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
||||
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions)
|
||||
}
|
||||
|
||||
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
||||
seen := make(map[*document.Node]bool)
|
||||
count := 0
|
||||
var visit func(*document.Node) bool
|
||||
visit = func(node *document.Node) bool {
|
||||
if node == nil || seen[node] {
|
||||
return true
|
||||
}
|
||||
seen[node] = true
|
||||
count++
|
||||
if count > limit {
|
||||
return false
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
if !visit(entry.Value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, child := range node.Sequence {
|
||||
if !visit(child) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if !visit(baseline) || !visit(input) || !visit(final) {
|
||||
return false
|
||||
}
|
||||
for _, application := range applications {
|
||||
if !visit(application.value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, resolution := range resolutions {
|
||||
if !visit(resolution.before) || !visit(resolution.after) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// WriteExplanation writes a deterministic human-readable rendering of Explain.
|
||||
// Unknown paths produce no output. Writer failures may leave partial output.
|
||||
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
|
||||
if r == nil || output == nil {
|
||||
return errExplanationUnavailable
|
||||
}
|
||||
explanation, ok := r.Explain(path)
|
||||
if !ok {
|
||||
return errExplanationUnknownPath
|
||||
}
|
||||
var rendered strings.Builder
|
||||
rendered.WriteString("path: ")
|
||||
rendered.WriteString(strconv.Quote(explanation.Path.String()))
|
||||
rendered.WriteByte('\n')
|
||||
writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources)
|
||||
writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0)
|
||||
if explanation.SecretReference != nil {
|
||||
rendered.WriteString("secret reference: ")
|
||||
rendered.WriteString(strconv.Quote(explanation.SecretReference.Name))
|
||||
rendered.WriteString(" (resolved from process environment)\n")
|
||||
}
|
||||
rendered.WriteString("history:\n")
|
||||
for _, change := range explanation.History {
|
||||
rendered.WriteString(" - representation: ")
|
||||
if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
|
||||
rendered.WriteString("policy-validated input\n")
|
||||
} else {
|
||||
rendered.WriteString("canonical typed\n")
|
||||
}
|
||||
writeExplanationOrigin(&rendered, " source", change.Origin, r.sources)
|
||||
writeExplanationSnapshot(&rendered, " before", change.Before, 4)
|
||||
writeExplanationSnapshot(&rendered, " after", change.After, 4)
|
||||
for _, reason := range change.Reasons {
|
||||
rendered.WriteString(" reason: ")
|
||||
rendered.WriteString(strconv.Quote(reason))
|
||||
rendered.WriteByte('\n')
|
||||
}
|
||||
for _, dependency := range change.Dependencies {
|
||||
rendered.WriteString(" dependency: ")
|
||||
rendered.WriteString(strconv.Quote(dependency.String()))
|
||||
rendered.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
data := []byte(rendered.String())
|
||||
written, err := output.Write(data)
|
||||
if err == nil && written != len(data) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) {
|
||||
output.WriteString(label)
|
||||
output.WriteString(": layer=")
|
||||
output.WriteString(strconv.Quote(string(origin.Layer)))
|
||||
if origin.Operation != "" {
|
||||
output.WriteString(" operation=")
|
||||
output.WriteString(strconv.Quote(string(origin.Operation)))
|
||||
}
|
||||
output.WriteByte('\n')
|
||||
for _, location := range origin.Locations {
|
||||
output.WriteString(" location: source=")
|
||||
output.WriteString(strconv.Quote(location.SourceID))
|
||||
if label := sourceLabel(sources, location.SourceID); label != "" {
|
||||
output.WriteString(" label=")
|
||||
output.WriteString(strconv.Quote(label))
|
||||
}
|
||||
output.WriteString(" line=")
|
||||
output.WriteString(strconv.Itoa(location.Line))
|
||||
output.WriteString(" column=")
|
||||
output.WriteString(strconv.Itoa(location.Column))
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
func sourceLabel(sources []SourceInfo, sourceID string) string {
|
||||
for _, source := range sources {
|
||||
if source.ID == sourceID {
|
||||
return source.Label
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) {
|
||||
if !snapshot.Present {
|
||||
output.WriteString(label)
|
||||
output.WriteString(": absent\n")
|
||||
return
|
||||
}
|
||||
output.WriteString(label)
|
||||
if snapshot.Redacted {
|
||||
output.WriteString(" (redacted)")
|
||||
}
|
||||
output.WriteString(":\n")
|
||||
prefix := strings.Repeat(" ", indent+2)
|
||||
for _, line := range strings.SplitAfter(snapshot.YAML, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
output.WriteString(prefix)
|
||||
output.WriteString(line)
|
||||
if !strings.HasSuffix(line, "\n") {
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
123
explain_render.go
Normal file
123
explain_render.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
errExplanationUnavailable = errors.New("configkit: configuration explanation is unavailable")
|
||||
errExplanationUnknownPath = errors.New("configkit: configuration explanation path is unknown")
|
||||
)
|
||||
|
||||
// WriteExplanation writes a deterministic human-readable rendering of Explain.
|
||||
// Unknown paths produce no output. Writer failures may leave partial output.
|
||||
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
|
||||
if r == nil || output == nil {
|
||||
return errExplanationUnavailable
|
||||
}
|
||||
explanation, ok := r.Explain(path)
|
||||
if !ok {
|
||||
return errExplanationUnknownPath
|
||||
}
|
||||
var rendered strings.Builder
|
||||
rendered.WriteString("path: ")
|
||||
rendered.WriteString(strconv.Quote(explanation.Path.String()))
|
||||
rendered.WriteByte('\n')
|
||||
writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources)
|
||||
writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0)
|
||||
if explanation.SecretReference != nil {
|
||||
rendered.WriteString("secret reference: ")
|
||||
rendered.WriteString(strconv.Quote(explanation.SecretReference.Name))
|
||||
rendered.WriteString(" (resolved from process environment)\n")
|
||||
}
|
||||
rendered.WriteString("history:\n")
|
||||
for _, change := range explanation.History {
|
||||
rendered.WriteString(" - representation: ")
|
||||
if change.Origin.Operation == ChangeDecoding {
|
||||
rendered.WriteString("input to canonical typed\n")
|
||||
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
|
||||
rendered.WriteString("policy-validated input\n")
|
||||
} else {
|
||||
rendered.WriteString("canonical typed\n")
|
||||
}
|
||||
writeExplanationOrigin(&rendered, " source", change.Origin, r.sources)
|
||||
writeExplanationSnapshot(&rendered, " before", change.Before, 4)
|
||||
writeExplanationSnapshot(&rendered, " after", change.After, 4)
|
||||
for _, reason := range change.Reasons {
|
||||
rendered.WriteString(" reason: ")
|
||||
rendered.WriteString(strconv.Quote(reason))
|
||||
rendered.WriteByte('\n')
|
||||
}
|
||||
for _, dependency := range change.Dependencies {
|
||||
rendered.WriteString(" dependency: ")
|
||||
rendered.WriteString(strconv.Quote(dependency.String()))
|
||||
rendered.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
data := []byte(rendered.String())
|
||||
written, err := output.Write(data)
|
||||
if err == nil && written != len(data) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) {
|
||||
output.WriteString(label)
|
||||
output.WriteString(": layer=")
|
||||
output.WriteString(strconv.Quote(string(origin.Layer)))
|
||||
if origin.Operation != "" {
|
||||
output.WriteString(" operation=")
|
||||
output.WriteString(strconv.Quote(string(origin.Operation)))
|
||||
}
|
||||
output.WriteByte('\n')
|
||||
for _, location := range origin.Locations {
|
||||
output.WriteString(" location: source=")
|
||||
output.WriteString(strconv.Quote(location.SourceID))
|
||||
if label := sourceLabel(sources, location.SourceID); label != "" {
|
||||
output.WriteString(" label=")
|
||||
output.WriteString(strconv.Quote(label))
|
||||
}
|
||||
output.WriteString(" line=")
|
||||
output.WriteString(strconv.Itoa(location.Line))
|
||||
output.WriteString(" column=")
|
||||
output.WriteString(strconv.Itoa(location.Column))
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
func sourceLabel(sources []SourceInfo, sourceID string) string {
|
||||
for _, source := range sources {
|
||||
if source.ID == sourceID {
|
||||
return source.Label
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) {
|
||||
if !snapshot.Present {
|
||||
output.WriteString(label)
|
||||
output.WriteString(": absent\n")
|
||||
return
|
||||
}
|
||||
output.WriteString(label)
|
||||
if snapshot.Redacted {
|
||||
output.WriteString(" (redacted)")
|
||||
}
|
||||
output.WriteString(":\n")
|
||||
prefix := strings.Repeat(" ", indent+2)
|
||||
for _, line := range strings.SplitAfter(snapshot.YAML, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
output.WriteString(prefix)
|
||||
output.WriteString(line)
|
||||
if !strings.HasSuffix(line, "\n") {
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
190
explain_test.go
190
explain_test.go
@@ -23,7 +23,7 @@ type explanationConcurrency struct {
|
||||
|
||||
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "config.yml", "_configkit:\n imports: [{path: transcription.yml}]\n")
|
||||
mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n")
|
||||
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
|
||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
|
||||
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
||||
@@ -326,3 +326,191 @@ func historyValueLayers(explanation Explanation) []Layer {
|
||||
}
|
||||
return layers
|
||||
}
|
||||
|
||||
func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
||||
type cfg struct {
|
||||
M map[string]int `yaml:"m"`
|
||||
}
|
||||
for _, base := range []string{"null", "replaced-text"} {
|
||||
for _, cli := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/cli=%v", base, cli), func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
main := writeImportFile(t, root, "main.yml", "m: "+base+"\n")
|
||||
opts := Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg { return cfg{M: map[string]int{"a": 1}} }}}
|
||||
if cli {
|
||||
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
|
||||
} else {
|
||||
writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n")
|
||||
opts.Profile = "p"
|
||||
}
|
||||
result, err := Load(File(main), opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []Path{Keys("m"), Keys("m", "a")} {
|
||||
explanation, ok := result.Explain(path)
|
||||
if !ok {
|
||||
t.Fatal("missing explanation")
|
||||
}
|
||||
last := explanation.History[len(explanation.History)-1]
|
||||
if last.After != explanation.Effective || last.Origin.Operation != ChangeDecoding {
|
||||
t.Fatalf("history does not reconcile final value: %#v", explanation)
|
||||
}
|
||||
info, _ := result.Metadata().Lookup(path)
|
||||
if last.Origin.Layer != info.Origin.Layer {
|
||||
t.Fatal("decoding lost effective origin")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegerVersionInspectionRetainsInputAndReconcilesTypedValue(t *testing.T) {
|
||||
type config struct {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, _ *Validation) error {
|
||||
if version.Text != "4" {
|
||||
t.Errorf("version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
options := Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config { return config{Version: "4"} },
|
||||
Version: rule,
|
||||
}}
|
||||
first, err := Load(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Load(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Config.Version != "4" || second.Config.Version != "4" {
|
||||
t.Fatalf("configurations = %#v / %#v", first.Config, second.Config)
|
||||
}
|
||||
inputVersion, inputOK := documentAtPath(first.applications[0].value, Keys("version"))
|
||||
finalVersion, finalOK := documentAtPath(first.final, Keys("version"))
|
||||
if !inputOK || inputVersion.Kind != document.Number || inputVersion.Text != "4" ||
|
||||
!finalOK || finalVersion.Kind != document.String || finalVersion.Text != "4" {
|
||||
t.Fatalf("input/final = %#v / %#v", inputVersion, finalVersion)
|
||||
}
|
||||
|
||||
info, ok := first.Metadata().Lookup(Keys("version"))
|
||||
if !ok || !info.Explicit || info.Null || info.Origin.Layer != LayerBase || len(info.Origin.Locations) != 1 ||
|
||||
info.Origin.Locations[0].SourceID != "source-1" || info.Origin.Locations[0].Line != 1 {
|
||||
t.Fatalf("version metadata = %#v, %v", info, ok)
|
||||
}
|
||||
explanation, ok := first.Explain(Keys("version"))
|
||||
if !ok || explanation.Effective.YAML != "'4'\n" {
|
||||
t.Fatalf("version explanation = %#v, %v", explanation, ok)
|
||||
}
|
||||
foundInput, foundDecoding := false, false
|
||||
for _, change := range explanation.History {
|
||||
if change.After.YAML == "4\n" && change.Origin.Layer == LayerBase {
|
||||
foundInput = true
|
||||
}
|
||||
if change.Before.YAML == "4\n" && change.After.YAML == "'4'\n" && change.Origin.Operation == ChangeDecoding {
|
||||
foundDecoding = true
|
||||
}
|
||||
if change.Origin.Layer == LayerResolution {
|
||||
t.Fatalf("conversion recorded as resolution: %#v", change)
|
||||
}
|
||||
}
|
||||
if !foundInput || !foundDecoding {
|
||||
t.Fatalf("version history = %#v", explanation.History)
|
||||
}
|
||||
paths := first.Metadata().HistoricalPaths()
|
||||
foundPath := false
|
||||
for _, path := range paths {
|
||||
if path.String() == "/version" {
|
||||
foundPath = true
|
||||
}
|
||||
}
|
||||
if !foundPath || len(first.Metadata().History(Keys("version"))) < 2 {
|
||||
t.Fatalf("historical version paths/history = %#v / %#v", paths, first.Metadata().History(Keys("version")))
|
||||
}
|
||||
|
||||
var effective strings.Builder
|
||||
if err := first.WriteEffective(&effective); err != nil || effective.String() != "version: '4'\n" {
|
||||
t.Fatalf("effective output = %q, %v", effective.String(), err)
|
||||
}
|
||||
first.Config.Version = "mutated"
|
||||
effective.Reset()
|
||||
if err := first.WriteEffective(&effective); err != nil || effective.String() != "version: '4'\n" || second.Config.Version != "4" {
|
||||
t.Fatalf("detached results = %q / %#v, %v", effective.String(), second.Config, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensitiveIntegerVersionStaysRedactedWithoutSecretLookup(t *testing.T) {
|
||||
type config struct {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
lookups := 0
|
||||
result, err := loadWithSecretLookup(Bytes("version.yml", []byte("version: 987654321\n")), func(string) (string, bool) {
|
||||
lookups++
|
||||
return "CREDENTIAL_CANARY", true
|
||||
}, Options[config]{Definition: Definition[config]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||
Secret: func(path Path) bool { return path.String() == "/version" },
|
||||
}})
|
||||
if err != nil || result.Config.Version != "987654321" || lookups != 0 {
|
||||
t.Fatalf("result = %#v, lookups = %d, err = %v", result, lookups, err)
|
||||
}
|
||||
var effective strings.Builder
|
||||
if err := result.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, ok := result.Explain(Keys("version"))
|
||||
formatted := effective.String() + fmt.Sprintf("%#v %#v", explanation, result.Metadata().History(Keys("version")))
|
||||
if !ok || !explanation.Effective.Redacted || strings.Contains(formatted, "987654321") || strings.Contains(formatted, "CREDENTIAL_CANARY") {
|
||||
t.Fatalf("sensitive inspection = %q / %#v", effective.String(), explanation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) {
|
||||
type cfg struct {
|
||||
M map[string]any `yaml:"m"`
|
||||
}
|
||||
for _, overlap := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(overlap), func(t *testing.T) {
|
||||
result, err := Load(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
|
||||
c.M["new"] = map[string]any{"child": int64(7)}
|
||||
delete(c.M, "old")
|
||||
r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old"))
|
||||
r.Record(Keys("m", "old"), ChangeNormalization, "remove old")
|
||||
if overlap {
|
||||
r.Record(Keys("m"), ChangeNormalization, "update container")
|
||||
}
|
||||
return nil
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []Path{Keys("m"), Keys("m", "new"), Keys("m", "new", "child"), Keys("m", "old")} {
|
||||
x, ok := result.Explain(path)
|
||||
if !ok {
|
||||
t.Fatal("missing known history")
|
||||
}
|
||||
var count int
|
||||
for _, v := range x.History {
|
||||
if v.Origin.Layer == LayerResolution {
|
||||
count++
|
||||
if overlap && len(v.Reasons) != 3 {
|
||||
t.Fatal("lost overlapping intent")
|
||||
}
|
||||
}
|
||||
}
|
||||
if overlap && count != 1 {
|
||||
t.Fatalf("overlapping records applied %d times", count)
|
||||
}
|
||||
last := x.History[len(x.History)-1]
|
||||
if last.After != x.Effective {
|
||||
t.Fatalf("incorrect replay for %s: %#v", path, x)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
121
filesystem.go
Normal file
121
filesystem.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||
if diagnostic != nil {
|
||||
return diagnostic
|
||||
}
|
||||
root, err := os.OpenRoot(rootPath)
|
||||
if err != nil {
|
||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||
value.Primary = nil
|
||||
return &value
|
||||
}
|
||||
if err := root.Close(); err != nil {
|
||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||
switch source.kind {
|
||||
case sourceNone:
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
|
||||
case sourceFile:
|
||||
if context.BaseDir != "" {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")}
|
||||
}
|
||||
case sourceBytes, sourceReader:
|
||||
if context.BaseDir == "" || context.Root == "" {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")}
|
||||
}
|
||||
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
|
||||
if main.source == nil || main.document == nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
|
||||
if source.kind == sourceFile {
|
||||
if context != nil && context.BaseDir != "" {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
|
||||
return "", "", "", &value
|
||||
}
|
||||
canonical, err := canonicalExistingPath(source.path)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
mainPath = canonical
|
||||
basePath = filepath.Dir(canonical)
|
||||
rootPath = basePath
|
||||
if context != nil && context.Root != "" {
|
||||
rootPath, err = canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if context == nil || context.BaseDir == "" || context.Root == "" {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
|
||||
return "", "", "", &value
|
||||
}
|
||||
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
|
||||
return "", "", "", &value
|
||||
}
|
||||
var err error
|
||||
basePath, err = canonicalExistingPath(context.BaseDir)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
rootPath, err = canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
}
|
||||
if !containedPath(rootPath, basePath) || (mainPath != "" && !containedPath(rootPath, mainPath)) {
|
||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
for _, candidate := range []string{rootPath, basePath} {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil || !info.IsDir() {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
}
|
||||
return rootPath, basePath, mainPath, nil
|
||||
}
|
||||
|
||||
func canonicalExistingPath(name string) (string, error) {
|
||||
absolute, err := filepath.Abs(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.EvalSymlinks(filepath.Clean(absolute))
|
||||
}
|
||||
|
||||
func containedPath(root, target string) bool {
|
||||
relative, err := filepath.Rel(root, target)
|
||||
if err != nil || filepath.IsAbs(relative) {
|
||||
return false
|
||||
}
|
||||
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
||||
}
|
||||
10
glob_test.go
10
glob_test.go
@@ -10,13 +10,13 @@ import (
|
||||
|
||||
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: fragments/*.yml
|
||||
- path: after.yml
|
||||
items: {root: root}
|
||||
`)
|
||||
aPath := writeImportFile(t, directory, "fragments/a.yml", `_configkit:
|
||||
aPath := writeImportFile(t, directory, "fragments/a.yml", `configkit:
|
||||
imports: [{path: nested/child.yml}]
|
||||
items: {alpha: alpha}
|
||||
`)
|
||||
@@ -56,7 +56,7 @@ items: {alpha: alpha}
|
||||
|
||||
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: 'conf.d/literal\*.yml'
|
||||
- glob: 'conf.d/item-[ab].yml'
|
||||
@@ -126,7 +126,7 @@ func TestLoadGlobImportsPreserveIdentityAndFileRules(t *testing.T) {
|
||||
t.Run("overlapping declarations", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: conf/*.yml
|
||||
- glob: conf/value.*
|
||||
@@ -269,5 +269,5 @@ func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) strin
|
||||
if optional {
|
||||
optionalLine = "\n optional: true"
|
||||
}
|
||||
return writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
|
||||
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
|
||||
}
|
||||
|
||||
276
history.go
Normal file
276
history.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
const historyNodeLimit = 4_000_000
|
||||
|
||||
// History applications borrow immutable policy-validated input trees. Metadata
|
||||
// records answer presence and origin queries; replay reconstructs value changes.
|
||||
// Replay reconciles with canonical decoding before domain resolution so both
|
||||
// contracts agree on the effective result without retaining a tree per field.
|
||||
type historyApplication struct {
|
||||
layer Layer
|
||||
path Path
|
||||
value *document.Node
|
||||
origin Origin
|
||||
}
|
||||
|
||||
func cloneHistoryApplications(values []historyApplication) []historyApplication {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]historyApplication, len(values))
|
||||
for index, value := range values {
|
||||
cloned[index] = historyApplication{
|
||||
layer: value.layer, path: value.path.clone(), value: value.value,
|
||||
origin: cloneOrigin(value.origin),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
|
||||
if !application.path.valid() || application.value == nil {
|
||||
return Path{}, false
|
||||
}
|
||||
if application.path.String() != "" {
|
||||
if pathsIntersect(application.path, query) {
|
||||
return application.path.clone(), true
|
||||
}
|
||||
return Path{}, false
|
||||
}
|
||||
if len(query.segments) == 0 {
|
||||
return Path{}, true
|
||||
}
|
||||
for length := 1; length <= len(query.segments); length++ {
|
||||
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
|
||||
node, present := documentAtPath(application.value, prefix)
|
||||
if !present {
|
||||
return Path{}, false
|
||||
}
|
||||
if length == len(query.segments) || node.Kind != document.Mapping {
|
||||
return prefix, true
|
||||
}
|
||||
}
|
||||
return Path{}, false
|
||||
}
|
||||
|
||||
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
|
||||
if application.origin.Layer != "" {
|
||||
return cloneOrigin(application.origin)
|
||||
}
|
||||
origin := Origin{Layer: application.layer}
|
||||
relative, ok := relativeHistoryPath(application.path, operationPath)
|
||||
if !ok {
|
||||
return origin
|
||||
}
|
||||
if node, present := documentAtPath(application.value, relative); present {
|
||||
origin.Locations = []Location{publicLocation(node.Location)}
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
func relativeHistoryPath(root, path Path) (Path, bool) {
|
||||
if !pathContains(root, path) {
|
||||
return Path{}, false
|
||||
}
|
||||
return Path{segments: clonePathSegments(path.segments[len(root.segments):], 0)}, true
|
||||
}
|
||||
|
||||
func pathsIntersect(left, right Path) bool {
|
||||
return pathContains(left, right) || pathContains(right, left)
|
||||
}
|
||||
|
||||
func applyHistoryApplication(root **document.Node, application historyApplication) {
|
||||
if application.path.String() == "" {
|
||||
mergeHistoryDocument(root, application.value)
|
||||
return
|
||||
}
|
||||
mergeHistoryAtPath(root, application.path, application.value)
|
||||
}
|
||||
|
||||
func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
|
||||
if incoming == nil {
|
||||
return
|
||||
}
|
||||
if *target == nil || (*target).Kind != document.Mapping || incoming.Kind != document.Mapping {
|
||||
*target = document.Clone(incoming)
|
||||
return
|
||||
}
|
||||
for _, entry := range incoming.Mapping {
|
||||
position := documentEntryIndex((*target).Mapping, entry.Key)
|
||||
if position < 0 {
|
||||
cloned := entry
|
||||
cloned.Value = document.Clone(entry.Value)
|
||||
(*target).Mapping = append((*target).Mapping, cloned)
|
||||
continue
|
||||
}
|
||||
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) {
|
||||
if root == nil || *root == nil || len(path.segments) == 0 {
|
||||
mergeHistoryDocument(root, incoming)
|
||||
return
|
||||
}
|
||||
current := *root
|
||||
for index, segment := range path.segments {
|
||||
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
|
||||
return
|
||||
}
|
||||
position := documentEntryIndex(current.Mapping, segment.key)
|
||||
if index == len(path.segments)-1 {
|
||||
if position < 0 {
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
|
||||
} else {
|
||||
mergeHistoryDocument(¤t.Mapping[position].Value, incoming)
|
||||
}
|
||||
return
|
||||
}
|
||||
if position < 0 {
|
||||
child := &document.Node{Kind: document.Mapping}
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
||||
current = child
|
||||
} else {
|
||||
current = current.Mapping[position].Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution records describe one before/final transformation, not assignment
|
||||
// order. A parent already carries its descendants' final values; merge overlapping
|
||||
// intent into that event rather than replay a child assignment a second time.
|
||||
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
||||
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
merged := false
|
||||
for i := range groups {
|
||||
if !pathContains(groups[i].change.Path, snapshot.change.Path) {
|
||||
continue
|
||||
}
|
||||
for _, reason := range snapshot.change.Reasons {
|
||||
groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason)
|
||||
}
|
||||
for _, dependency := range snapshot.change.Dependencies {
|
||||
groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency)
|
||||
}
|
||||
if snapshot.change.Origin.Operation == ChangeNormalization {
|
||||
groups[i].change.Origin.Operation = ChangeNormalization
|
||||
}
|
||||
merged = true
|
||||
break
|
||||
}
|
||||
if !merged {
|
||||
snapshot.change = cloneChange(snapshot.change)
|
||||
groups = append(groups, snapshot)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) {
|
||||
if root == nil || *root == nil {
|
||||
return
|
||||
}
|
||||
if len(path.segments) == 0 {
|
||||
*root = document.Clone(replacement)
|
||||
return
|
||||
}
|
||||
current := *root
|
||||
for index, segment := range path.segments {
|
||||
last := index == len(path.segments)-1
|
||||
switch current.Kind {
|
||||
case document.Mapping:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return
|
||||
}
|
||||
position := documentEntryIndex(current.Mapping, segment.key)
|
||||
if position < 0 {
|
||||
if replacement == nil {
|
||||
return
|
||||
}
|
||||
child := &document.Node{Kind: document.Mapping}
|
||||
if last {
|
||||
child = document.Clone(replacement)
|
||||
}
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
||||
if last {
|
||||
return
|
||||
}
|
||||
current = child
|
||||
continue
|
||||
}
|
||||
if last {
|
||||
if replacement == nil {
|
||||
current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...)
|
||||
} else {
|
||||
current.Mapping[position].Value = document.Clone(replacement)
|
||||
}
|
||||
return
|
||||
}
|
||||
current = current.Mapping[position].Value
|
||||
case document.Sequence:
|
||||
position, ok := schemaIndex(segment)
|
||||
if !ok || position >= len(current.Sequence) {
|
||||
return
|
||||
}
|
||||
if last {
|
||||
if replacement != nil {
|
||||
current.Sequence[position] = document.Clone(replacement)
|
||||
}
|
||||
return
|
||||
}
|
||||
current = current.Sequence[position]
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
||||
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions)
|
||||
}
|
||||
|
||||
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
||||
seen := make(map[*document.Node]bool)
|
||||
count := 0
|
||||
var visit func(*document.Node) bool
|
||||
visit = func(node *document.Node) bool {
|
||||
if node == nil || seen[node] {
|
||||
return true
|
||||
}
|
||||
seen[node] = true
|
||||
count++
|
||||
if count > limit {
|
||||
return false
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
if !visit(entry.Value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, child := range node.Sequence {
|
||||
if !visit(child) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if !visit(baseline) || !visit(input) || !visit(final) {
|
||||
return false
|
||||
}
|
||||
for _, application := range applications {
|
||||
if !visit(application.value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, resolution := range resolutions {
|
||||
if !visit(resolution.before) || !visit(resolution.after) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
352
imports.go
352
imports.go
@@ -3,7 +3,6 @@ package configkit
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
@@ -36,13 +35,6 @@ func defaultImportLimits() importLimits {
|
||||
}
|
||||
}
|
||||
|
||||
type importDeclaration struct {
|
||||
path string
|
||||
glob string
|
||||
optional bool
|
||||
location Location
|
||||
}
|
||||
|
||||
type importRoute struct {
|
||||
canonical string
|
||||
locations []Location
|
||||
@@ -121,120 +113,6 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
|
||||
return composed, cloneSourceInfos(state.sources), diagnostics
|
||||
}
|
||||
|
||||
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
||||
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
|
||||
if diagnostic != nil {
|
||||
return diagnostic
|
||||
}
|
||||
root, err := os.OpenRoot(rootPath)
|
||||
if err != nil {
|
||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||
value.Primary = nil
|
||||
return &value
|
||||
}
|
||||
if err := root.Close(); err != nil {
|
||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||
switch source.kind {
|
||||
case sourceNone:
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
|
||||
case sourceFile:
|
||||
if context.BaseDir != "" {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")}
|
||||
}
|
||||
case sourceBytes, sourceReader:
|
||||
if context.BaseDir == "" || context.Root == "" {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")}
|
||||
}
|
||||
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
|
||||
if main.source == nil || main.document == nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
|
||||
if source.kind == sourceFile {
|
||||
if context != nil && context.BaseDir != "" {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
|
||||
return "", "", "", &value
|
||||
}
|
||||
canonical, err := canonicalExistingPath(source.path)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
mainPath = canonical
|
||||
basePath = filepath.Dir(canonical)
|
||||
rootPath = basePath
|
||||
if context != nil && context.Root != "" {
|
||||
rootPath, err = canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if context == nil || context.BaseDir == "" || context.Root == "" {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
|
||||
return "", "", "", &value
|
||||
}
|
||||
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
|
||||
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
|
||||
return "", "", "", &value
|
||||
}
|
||||
var err error
|
||||
basePath, err = canonicalExistingPath(context.BaseDir)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
rootPath, err = canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
}
|
||||
if !containedPath(rootPath, basePath) || (mainPath != "" && !containedPath(rootPath, mainPath)) {
|
||||
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
for _, candidate := range []string{rootPath, basePath} {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil || !info.IsDir() {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
||||
return "", "", "", &value
|
||||
}
|
||||
}
|
||||
return rootPath, basePath, mainPath, nil
|
||||
}
|
||||
|
||||
func canonicalExistingPath(name string) (string, error) {
|
||||
absolute, err := filepath.Abs(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.EvalSymlinks(filepath.Clean(absolute))
|
||||
}
|
||||
|
||||
func containedPath(root, target string) bool {
|
||||
relative, err := filepath.Rel(root, target)
|
||||
if err != nil || filepath.IsAbs(relative) {
|
||||
return false
|
||||
}
|
||||
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||
if s.limits.sources < 1 {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||
@@ -345,7 +223,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
||||
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
||||
})
|
||||
if len(issues) != 0 {
|
||||
return importParseDiagnostics(sourceID, issues)
|
||||
return parserDiagnostics(sourceID, issues)
|
||||
}
|
||||
nodeCount := countInputNodes(root)
|
||||
if s.usedNodes+nodeCount > s.limits.nodes {
|
||||
@@ -382,35 +260,12 @@ func (s *importTraversal) readImport(relative, sourceID string, optional bool) (
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
file, err := s.root.Open(relative)
|
||||
if err != nil {
|
||||
if optional && errors.Is(err, fs.ErrNotExist) {
|
||||
data, failure := readRegularFile(relative, s.root.Stat, s.root.Open, limit)
|
||||
if failure != nil {
|
||||
if optional && failure.missingTarget {
|
||||
return nil, true, nil
|
||||
}
|
||||
value := classifySourceError(err, sourceID)
|
||||
return nil, false, &value
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
value := classifySourceError(err, sourceID)
|
||||
return nil, false, &value
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
||||
return nil, false, &value
|
||||
}
|
||||
if info.Size() > limit {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
||||
return nil, false, &value
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||
if err != nil {
|
||||
value := classifySourceError(err, sourceID)
|
||||
return nil, false, &value
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
||||
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
||||
return nil, false, &value
|
||||
}
|
||||
s.usedBytes += int64(len(data))
|
||||
@@ -428,184 +283,6 @@ func (s *importTraversal) currentRoute(location Location) importRoute {
|
||||
return importRoute{locations: locations}
|
||||
}
|
||||
|
||||
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
|
||||
body := document.Clone(root)
|
||||
var declarations []importDeclaration
|
||||
directiveIndex := -1
|
||||
for index, entry := range body.Mapping {
|
||||
if entry.Key != "_configkit" {
|
||||
continue
|
||||
}
|
||||
directiveIndex = index
|
||||
parsed, diagnostics := parseImportDirective(entry.Value, Keys("_configkit"))
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, nil, diagnostics
|
||||
}
|
||||
declarations = parsed
|
||||
break
|
||||
}
|
||||
if directiveIndex >= 0 {
|
||||
body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...)
|
||||
}
|
||||
if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 {
|
||||
return nil, nil, diagnostics
|
||||
}
|
||||
return body, declarations, nil
|
||||
}
|
||||
|
||||
func parseImportDirective(node *document.Node, directivePath Path) ([]importDeclaration, []Diagnostic) {
|
||||
if node.Kind != document.Mapping {
|
||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
|
||||
}
|
||||
var declarations []importDeclaration
|
||||
for _, entry := range node.Mapping {
|
||||
fieldPath := directivePath.Key(entry.Key)
|
||||
if entry.Key != "imports" {
|
||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value)}
|
||||
}
|
||||
if entry.Value.Kind != document.Sequence {
|
||||
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)}
|
||||
}
|
||||
for index, item := range entry.Value.Sequence {
|
||||
declaration, diagnostics := parseImportDeclaration(item, fieldPath.Index(index))
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
declarations = append(declarations, declaration)
|
||||
}
|
||||
}
|
||||
return declarations, nil
|
||||
}
|
||||
|
||||
func parseImportDeclaration(node *document.Node, declarationPath Path) (importDeclaration, []Diagnostic) {
|
||||
if node.Kind != document.Mapping {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
|
||||
}
|
||||
declaration := importDeclaration{location: publicLocation(node.Location)}
|
||||
seenTarget := false
|
||||
for _, entry := range node.Mapping {
|
||||
fieldPath := declarationPath.Key(entry.Key)
|
||||
switch entry.Key {
|
||||
case "path", "glob":
|
||||
if seenTarget || entry.Value.Kind != document.String || entry.Value.Text == "" || strings.TrimSpace(entry.Value.Text) != entry.Value.Text {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration requires exactly one nonempty path or glob string", entry.Value)}
|
||||
}
|
||||
seenTarget = true
|
||||
declaration.location = publicLocation(entry.Value.Location)
|
||||
if entry.Key == "path" {
|
||||
declaration.path = entry.Value.Text
|
||||
} else {
|
||||
declaration.glob = entry.Value.Text
|
||||
}
|
||||
case "optional":
|
||||
if entry.Value.Kind != document.Boolean {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
|
||||
}
|
||||
declaration.optional = entry.Value.Text == "true"
|
||||
default:
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
|
||||
}
|
||||
}
|
||||
if !seenTarget {
|
||||
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path or glob", node)}
|
||||
}
|
||||
return declaration, nil
|
||||
}
|
||||
|
||||
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||
if node.Kind == document.Mapping {
|
||||
for _, entry := range node.Mapping {
|
||||
childPath := current.Key(entry.Key)
|
||||
if entry.Key == "_configkit" {
|
||||
location := publicLocation(entry.KeyLocation)
|
||||
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: childPath, Message: "configuration directives are permitted only at the document root", Primary: &location}}
|
||||
}
|
||||
if diagnostics := nestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for index, child := range node.Sequence {
|
||||
if diagnostics := nestedDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
current := root
|
||||
if len(rule.Path.segments) == 0 {
|
||||
return current
|
||||
}
|
||||
for _, segment := range rule.Path.segments {
|
||||
if current.Kind != document.Mapping {
|
||||
return current
|
||||
}
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return nil
|
||||
}
|
||||
key := segment.key
|
||||
index := documentEntryIndex(current.Mapping, key)
|
||||
if index < 0 {
|
||||
return nil
|
||||
}
|
||||
current = current.Mapping[index].Value
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func countInputNodes(node *document.Node) int {
|
||||
if node == nil {
|
||||
return 0
|
||||
}
|
||||
count := 1
|
||||
if node.Kind == document.Mapping {
|
||||
count += len(node.Mapping)
|
||||
for _, entry := range node.Mapping {
|
||||
count += countInputNodes(entry.Value)
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for _, child := range node.Sequence {
|
||||
count += countInputNodes(child)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func validImportPath(value string) bool {
|
||||
if value == "" || strings.TrimSpace(value) != value || path.IsAbs(value) || strings.HasPrefix(value, "//") {
|
||||
return false
|
||||
}
|
||||
return !hasImportScheme(value)
|
||||
}
|
||||
|
||||
func hasImportScheme(value string) bool {
|
||||
colon := strings.IndexByte(value, ':')
|
||||
slash := strings.IndexByte(value, '/')
|
||||
if colon <= 0 || (slash >= 0 && slash < colon) {
|
||||
return false
|
||||
}
|
||||
for index := 0; index < colon; index++ {
|
||||
character := value[index]
|
||||
if index == 0 {
|
||||
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') &&
|
||||
(character < '0' || character > '9') && character != '+' && character != '-' && character != '.' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sourceInfos(source *SourceInfo) []SourceInfo {
|
||||
if source == nil {
|
||||
return nil
|
||||
@@ -613,29 +290,10 @@ func sourceInfos(source *SourceInfo) []SourceInfo {
|
||||
return []SourceInfo{cloneSourceInfo(*source)}
|
||||
}
|
||||
|
||||
func importParseDiagnostics(sourceID string, issues []yamladapter.Issue) []Diagnostic {
|
||||
diagnostics := make([]Diagnostic, len(issues))
|
||||
for index, issue := range issues {
|
||||
diagnostics[index] = Diagnostic{
|
||||
Code: Code(issue.Code), Severity: SeverityError, Message: issue.Message,
|
||||
Primary: &Location{SourceID: sourceID, Line: issue.Line, Column: issue.Column},
|
||||
}
|
||||
for _, related := range issue.Related {
|
||||
diagnostics[index].Related = append(diagnostics[index].Related, publicLocation(related))
|
||||
}
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func importDiagnostic(code Code, message string, location *Location) Diagnostic {
|
||||
return Diagnostic{Code: code, Severity: SeverityError, Message: message, Primary: cloneLocation(location)}
|
||||
}
|
||||
|
||||
func nodeImportDiagnostic(code Code, path Path, message string, node *document.Node) Diagnostic {
|
||||
location := publicLocation(node.Location)
|
||||
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
|
||||
}
|
||||
|
||||
func importCycleDiagnostic(target string, location Location, cycle []importRoute) Diagnostic {
|
||||
paths := make([]string, 0, len(cycle)+1)
|
||||
related := make([]Location, 0, len(cycle))
|
||||
|
||||
@@ -22,16 +22,37 @@ type importTestConfig struct {
|
||||
Items map[string]string `yaml:"items"`
|
||||
}
|
||||
|
||||
func TestLoadFormerDirectiveKeyIsApplicationData(t *testing.T) {
|
||||
type config struct {
|
||||
Legacy struct {
|
||||
Imports []struct {
|
||||
Path string `yaml:"path"`
|
||||
} `yaml:"imports"`
|
||||
} `yaml:"_configkit"`
|
||||
}
|
||||
for _, imports := range []bool{false, true} {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: nonexistent.yml\n")
|
||||
result, err := Load[config](File(mainPath), Options[config]{Imports: imports})
|
||||
if err != nil {
|
||||
t.Fatalf("Imports=%v: %v", imports, err)
|
||||
}
|
||||
if len(result.Config.Legacy.Imports) != 1 || result.Config.Legacy.Imports[0].Path != "nonexistent.yml" || len(result.Sources()) != 1 {
|
||||
t.Fatalf("Imports=%v: former directive was not retained as application data: %#v", imports, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTraversesPathImportsDepthFirstAndComposesOrigins(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: first.yml
|
||||
- path: second.yml
|
||||
settings:
|
||||
root: root
|
||||
`)
|
||||
firstPath := writeImportFile(t, directory, "first.yml", `_configkit:
|
||||
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
|
||||
imports:
|
||||
- path: nested/third.yml
|
||||
settings:
|
||||
@@ -71,7 +92,7 @@ settings:
|
||||
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
||||
t.Run("all duplicate claimants", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: one.yml
|
||||
- path: two.yml
|
||||
@@ -92,12 +113,12 @@ func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
||||
name string
|
||||
fragment string
|
||||
}{
|
||||
{name: "exact", fragment: "settings:\n version: v2\n"},
|
||||
{name: "exact integer", fragment: "settings:\n version: 4\n"},
|
||||
{name: "ancestor scalar", fragment: "settings: blocked\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: fragment.yml\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: fragment.yml\n")
|
||||
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
@@ -114,15 +135,15 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
||||
body string
|
||||
code Code
|
||||
}{
|
||||
{name: "directive kind", body: "_configkit: []\n", code: CodeImportInvalid},
|
||||
{name: "unknown directive field", body: "_configkit: {unknown: true}\n", code: CodeImportInvalid},
|
||||
{name: "imports kind", body: "_configkit: {imports: {}}\n", code: CodeImportInvalid},
|
||||
{name: "declaration kind", body: "_configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
|
||||
{name: "missing target", body: "_configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
|
||||
{name: "two targets", body: "_configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
||||
{name: "unknown declaration field", body: "_configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
||||
{name: "optional kind", body: "_configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
||||
{name: "nested directive", body: "nested: {_configkit: {}}\n", code: CodeImportInvalid},
|
||||
{name: "directive kind", body: "configkit: []\n", code: CodeImportInvalid},
|
||||
{name: "unknown directive field", body: "configkit: {unknown: true}\n", code: CodeImportInvalid},
|
||||
{name: "imports kind", body: "configkit: {imports: {}}\n", code: CodeImportInvalid},
|
||||
{name: "declaration kind", body: "configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
|
||||
{name: "missing target", body: "configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
|
||||
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
||||
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
||||
{name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
||||
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -136,7 +157,7 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
||||
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
||||
t.Run("optional missing", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: missing.yml
|
||||
optional: true
|
||||
@@ -172,7 +193,7 @@ settings: {root: retained}
|
||||
if test.setup != nil {
|
||||
test.setup(t, directory)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: \""+test.path+"\"\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: \""+test.path+"\"\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
||||
})
|
||||
}
|
||||
@@ -181,9 +202,9 @@ settings: {root: retained}
|
||||
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
||||
t.Run("cycle", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: a.yml}]\n")
|
||||
writeImportFile(t, directory, "a.yml", "_configkit:\n imports: [{path: b.yml}]\n")
|
||||
writeImportFile(t, directory, "b.yml", "_configkit:\n imports: [{path: main.yml}]\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: a.yml}]\n")
|
||||
writeImportFile(t, directory, "a.yml", "configkit:\n imports: [{path: b.yml}]\n")
|
||||
writeImportFile(t, directory, "b.yml", "configkit:\n imports: [{path: main.yml}]\n")
|
||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Primary == nil || len(diagnostic.Related) != 2 || !strings.Contains(diagnostic.Message, "main.yml") || !strings.Contains(diagnostic.Message, "a.yml") || !strings.Contains(diagnostic.Message, "b.yml") {
|
||||
@@ -193,9 +214,9 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
||||
|
||||
t.Run("diamond", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
|
||||
writeImportFile(t, directory, "left.yml", "_configkit:\n imports: [{path: shared.yml}]\n")
|
||||
writeImportFile(t, directory, "right.yml", "_configkit:\n imports: [{path: shared.yml}]\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
|
||||
writeImportFile(t, directory, "left.yml", "configkit:\n imports: [{path: shared.yml}]\n")
|
||||
writeImportFile(t, directory, "right.yml", "configkit:\n imports: [{path: shared.yml}]\n")
|
||||
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
|
||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
@@ -216,7 +237,7 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
||||
if err := os.Symlink("target.yml", filepath.Join(directory, "second-link.yml")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||
})
|
||||
}
|
||||
@@ -231,7 +252,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
||||
if err := os.Symlink(target, filepath.Join(directory, "link.yml")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: link.yml}]}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: link.yml}]}\n")
|
||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) {
|
||||
t.Fatalf("symlink result = %#v, %v", result, err)
|
||||
@@ -245,7 +266,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
||||
if err := os.Symlink(target, filepath.Join(root, "escape.yml")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, root, "main.yml", "_configkit: {imports: [{path: escape.yml}]}\n")
|
||||
mainPath := writeImportFile(t, root, "main.yml", "configkit: {imports: [{path: escape.yml}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||
})
|
||||
|
||||
@@ -255,7 +276,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
||||
if err := os.Link(one, filepath.Join(directory, "two.yml")); err != nil {
|
||||
t.Skipf("hard links unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
|
||||
})
|
||||
|
||||
@@ -264,7 +285,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
||||
if err := os.Symlink("loop.yml", filepath.Join(directory, "loop.yml")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: loop.yml}]}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: loop.yml}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
|
||||
})
|
||||
}
|
||||
@@ -272,7 +293,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
||||
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "fragment.yml", "settings: {first: imported}\n")
|
||||
input := []byte("_configkit: {imports: [{path: fragment.yml}]}\n")
|
||||
input := []byte("configkit: {imports: [{path: fragment.yml}]}\n")
|
||||
context := &FilesystemContext{BaseDir: directory, Root: directory}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
@@ -295,7 +316,7 @@ func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T)
|
||||
|
||||
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mainPath := writeImportFile(t, root, "configs/main.yml", "_configkit: {imports: [{path: ../shared.yml}]}\n")
|
||||
mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\n")
|
||||
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
|
||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
||||
@@ -309,7 +330,7 @@ func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
||||
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
||||
t.Run("parse", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: broken.yml}]}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
|
||||
writeImportFile(t, directory, "broken.yml", "settings: [\n")
|
||||
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
diagnostics := loadError.Diagnostics()
|
||||
@@ -320,7 +341,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
||||
|
||||
t.Run("decode", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: unknown.yml}]}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: unknown.yml}]}\n")
|
||||
writeImportFile(t, directory, "unknown.yml", "unknown: value\n")
|
||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
@@ -332,7 +353,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
||||
|
||||
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "outside.yml", "_configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
|
||||
mainPath := writeImportFile(t, directory, "outside.yml", "configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||
|
||||
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
||||
@@ -344,13 +365,13 @@ func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
||||
_ = file.Close()
|
||||
t.Skip("filesystem does not enforce the unreadable-file fixture")
|
||||
}
|
||||
mainPath = writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: locked.yml, optional: true}]}\n")
|
||||
mainPath = writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: locked.yml, optional: true}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
|
||||
}
|
||||
|
||||
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
|
||||
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
|
||||
main := parseImportMain(t, mainPath)
|
||||
@@ -370,7 +391,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
writeImportFile(t, directory, "one.yml", "_configkit: {imports: [{path: nested.yml}]}\n")
|
||||
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
|
||||
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
||||
main = parseImportMain(t, mainPath)
|
||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
||||
@@ -381,7 +402,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
||||
|
||||
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: imported.yml}]}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: imported.yml}]}\n")
|
||||
importedPath := writeImportFile(t, directory, "imported.yml", "settings: {first: imported}\n")
|
||||
main := parseImportMain(t, mainPath)
|
||||
imported := parseImportMain(t, importedPath)
|
||||
|
||||
@@ -32,11 +32,14 @@ type Entry struct {
|
||||
// Node is ConfigKit's owned configuration representation. Number Text retains its
|
||||
// exact validated decimal spelling; Boolean Text is "true" or "false".
|
||||
type Node struct {
|
||||
Kind Kind
|
||||
Location Location
|
||||
Text string
|
||||
Mapping []Entry
|
||||
Sequence []*Node
|
||||
// Sensitive preserves field classification from concrete typed projections.
|
||||
// It is metadata, not part of the configuration value or emitted YAML.
|
||||
Sensitive bool
|
||||
Kind Kind
|
||||
Location Location
|
||||
Text string
|
||||
Mapping []Entry
|
||||
Sequence []*Node
|
||||
}
|
||||
|
||||
// Clone returns a detached copy of a node tree.
|
||||
|
||||
334
load.go
334
load.go
@@ -51,12 +51,14 @@ type Resolution struct {
|
||||
}
|
||||
|
||||
// VersionRule declares the input path and application checker for a schema version.
|
||||
// At that path, string and decimal-integer input is supplied as Version.Text.
|
||||
type VersionRule struct {
|
||||
Path Path
|
||||
Check func(Version, *Validation) error
|
||||
}
|
||||
|
||||
// Version preserves the absent, null, and string states of a schema-version input.
|
||||
// Version preserves the absent, null, and textual states of a schema-version input.
|
||||
// Text is the exact spelling of accepted string or decimal-integer input.
|
||||
type Version struct {
|
||||
Present bool
|
||||
Null bool
|
||||
@@ -72,6 +74,7 @@ type Result[T any] struct {
|
||||
diagnostics []Diagnostic
|
||||
baseline *document.Node
|
||||
input *document.Node
|
||||
inputOrigins map[string]Origin
|
||||
final *document.Node
|
||||
resolution []resolutionSnapshot
|
||||
applications []historyApplication
|
||||
@@ -113,332 +116,69 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
|
||||
func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
|
||||
accumulator := diagnosticAccumulator{}
|
||||
options, diagnostics := selectOptions(source, supplied)
|
||||
accumulator.add(diagnostics...)
|
||||
if len(diagnostics) != 0 {
|
||||
accumulator.add(diagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
|
||||
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
|
||||
input, diagnostics := assembleBaseInput(source, options)
|
||||
accumulator.sources = input.sources
|
||||
accumulator.add(diagnostics...)
|
||||
if len(diagnostics) != 0 {
|
||||
if parsed.source != nil {
|
||||
accumulator.sources = append(accumulator.sources, *parsed.source)
|
||||
}
|
||||
accumulator.add(diagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
|
||||
inputDocument := parsed.document
|
||||
var composition *composedDocument
|
||||
if options.Imports {
|
||||
composition, accumulator.sources, diagnostics = loadImportedDocuments(source, options.Filesystem, parsed, options.Definition.Version, defaultImportLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
accumulator.add(diagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
inputDocument = composition.root
|
||||
} else {
|
||||
if parsed.source != nil {
|
||||
accumulator.sources = append(accumulator.sources, *parsed.source)
|
||||
}
|
||||
if options.Filesystem != nil && !activeProfile(options.Profile) {
|
||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
||||
accumulator.add(*diagnostic)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
}
|
||||
}
|
||||
if !options.Imports && inputDocument != nil {
|
||||
directiveDiagnostics := disabledDirectiveDiagnostics(inputDocument, Path{})
|
||||
if len(directiveDiagnostics) != 0 {
|
||||
accumulator.add(directiveDiagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
}
|
||||
|
||||
schema, schemaIssue := buildSchema(reflect.TypeFor[T]())
|
||||
if schemaIssue != nil {
|
||||
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
|
||||
versionDiagnostics := checkVersion(options.Definition.Version, inputDocument)
|
||||
versionDiagnostics := checkVersion(options.Definition.Version, input.document)
|
||||
accumulator.add(versionDiagnostics...)
|
||||
if hasErrors(versionDiagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
if activeProfile(options.Profile) {
|
||||
if composition == nil {
|
||||
composition, diagnostics = composeDocuments(inputDocument)
|
||||
if len(diagnostics) != 0 {
|
||||
accumulator.add(diagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
composition.inputBytes = parsed.bytes
|
||||
composition.inputNodes = countInputNodes(parsed.document)
|
||||
}
|
||||
composition, accumulator.sources, diagnostics = loadProfileDocument(
|
||||
source, options.Filesystem, parsed, composition, accumulator.sources,
|
||||
options.Profile, options.Definition.Version, defaultImportLimits(),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
accumulator.add(diagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
inputDocument = composition.root
|
||||
}
|
||||
if len(options.Overrides) != 0 {
|
||||
if composition == nil {
|
||||
if inputDocument == nil {
|
||||
composition = &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}
|
||||
} else {
|
||||
composition, diagnostics = composeDocuments(inputDocument)
|
||||
if len(diagnostics) != 0 {
|
||||
accumulator.add(diagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
composition.inputBytes = parsed.bytes
|
||||
composition.inputNodes = countInputNodes(inputDocument)
|
||||
}
|
||||
}
|
||||
composition, accumulator.sources, diagnostics = applyOverrides(
|
||||
composition, accumulator.sources, options.Overrides, options.Definition.Version, defaultImportLimits(),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
accumulator.add(diagnostics...)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
inputDocument = composition.root
|
||||
}
|
||||
|
||||
defaults := reflect.Zero(reflect.TypeFor[T]())
|
||||
if options.Definition.Defaults != nil {
|
||||
defaults = reflect.ValueOf(options.Definition.Defaults())
|
||||
}
|
||||
decoded, decodeIssues := decodeConfiguration(schema, defaults, inputDocument)
|
||||
if len(decodeIssues) != 0 {
|
||||
for _, issue := range decodeIssues {
|
||||
accumulator.add(diagnosticFromDecodeIssue(issue))
|
||||
}
|
||||
input, diagnostics = applyInputLayers(source, options, input)
|
||||
accumulator.sources = input.sources
|
||||
accumulator.add(diagnostics...)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
if composition != nil {
|
||||
applyCompositionOrigins(&decoded, composition.origins)
|
||||
}
|
||||
|
||||
configuration := decoded.value.Interface().(T)
|
||||
metadata := metadataFromDecoded(decoded)
|
||||
if composition != nil {
|
||||
applyOverlayMetadata(&metadata, composition.overlays)
|
||||
decoded, diagnostics := decodeLoadInput(schema, options.Definition, input)
|
||||
accumulator.add(diagnostics...)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
final := decoded.final
|
||||
var resolutionSnapshots []resolutionSnapshot
|
||||
if options.Definition.Resolve != nil {
|
||||
resolved := runResolution(&configuration, options.Definition.Resolve, schema, decoded, inputDocument)
|
||||
accumulator.add(resolved.diagnostics...)
|
||||
if hasErrors(resolved.diagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
final = resolved.final
|
||||
resolutionSnapshots = resolved.snapshots
|
||||
applyResolutionMetadata(&metadata, resolved)
|
||||
resolved, diagnostics := resolveLoadInput(schema, options.Definition, input, decoded)
|
||||
accumulator.add(diagnostics...)
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
applications := []historyApplication(nil)
|
||||
if composition != nil {
|
||||
applications = cloneHistoryApplications(composition.applications)
|
||||
} else if inputDocument != nil {
|
||||
applications = []historyApplication{{layer: LayerBase, value: inputDocument}}
|
||||
}
|
||||
if !historyNodesWithinLimit(decoded.baseline, decoded.final, final, applications, resolutionSnapshots) {
|
||||
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) {
|
||||
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, inputDocument, lookup)
|
||||
|
||||
// Detach resolver-borrowed values before reference mutation or validation.
|
||||
configuration, ownershipIssue := detachLoadConfiguration(schema, resolved.configuration, cloneUnresolvedSecrets)
|
||||
if ownershipIssue != nil {
|
||||
accumulator.add(*ownershipIssue)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, input.document, lookup)
|
||||
accumulator.add(secretResolution.diagnostics...)
|
||||
if hasErrors(secretResolution.diagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
if options.Definition.Validate != nil {
|
||||
validationDiagnostics := runValidation(&configuration, options.Definition.Validate, schema, final, inputDocument, secretResolution.lookups)
|
||||
accumulator.add(validationDiagnostics...)
|
||||
if hasErrors(validationDiagnostics) {
|
||||
diagnostics = runValidation(&configuration, options.Definition.Validate, schema, resolved.final, input.document, secretResolution.lookups)
|
||||
accumulator.add(diagnostics...)
|
||||
if hasErrors(diagnostics) {
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
}
|
||||
secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final, applications)
|
||||
return &Result[T]{
|
||||
Config: configuration,
|
||||
metadata: metadata,
|
||||
sources: cloneSourceInfos(accumulator.sources),
|
||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||
baseline: decoded.baseline,
|
||||
input: decoded.final,
|
||||
final: final,
|
||||
resolution: resolutionSnapshots,
|
||||
applications: applications,
|
||||
secrets: secrets,
|
||||
secretReferences: cloneSecretReferences(secretResolution.references),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) {
|
||||
if len(supplied) > 1 {
|
||||
return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}
|
||||
}
|
||||
var options Options[T]
|
||||
if len(supplied) == 1 {
|
||||
options = supplied[0]
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
definition := options.Definition
|
||||
if !validProfileName(options.Profile) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen"))
|
||||
}
|
||||
if options.Filesystem != nil {
|
||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
||||
}
|
||||
if source.kind == sourceNone && options.Imports {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
|
||||
}
|
||||
if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile"))
|
||||
}
|
||||
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context"))
|
||||
}
|
||||
if definition.Version != nil {
|
||||
if !definition.Version.Path.valid() {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))
|
||||
}
|
||||
if definition.Version.Check == nil {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
|
||||
}
|
||||
}
|
||||
return options, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
func optionDiagnostic(code Code, message string) Diagnostic {
|
||||
return Diagnostic{Code: code, Severity: SeverityError, Message: message}
|
||||
}
|
||||
|
||||
func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
|
||||
var diagnostics []Diagnostic
|
||||
if node.Kind == document.Mapping {
|
||||
for _, entry := range node.Mapping {
|
||||
childPath := path.Key(entry.Key)
|
||||
if entry.Key == "_configkit" {
|
||||
location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column}
|
||||
diagnostics = append(diagnostics, Diagnostic{
|
||||
Code: CodeImportDisabled, Severity: SeverityError, Path: childPath,
|
||||
Message: "configuration directives require imports to be enabled", Primary: &location,
|
||||
})
|
||||
}
|
||||
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...)
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for index, child := range node.Sequence {
|
||||
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...)
|
||||
}
|
||||
}
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
version := Version{}
|
||||
node, present := documentAtPath(root, rule.Path)
|
||||
if present {
|
||||
version.Present = true
|
||||
switch node.Kind {
|
||||
case document.Null:
|
||||
version.Null = true
|
||||
case document.String:
|
||||
version.Text = node.Text
|
||||
default:
|
||||
location, _ := nearestDocumentLocation(root, rule.Path)
|
||||
primary := documentLocationPointer(location)
|
||||
return []Diagnostic{{
|
||||
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "schema version must be a string or null", Primary: primary,
|
||||
}}
|
||||
}
|
||||
}
|
||||
state := &validationState{
|
||||
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
|
||||
invalidPath: CodeVersionError, document: root,
|
||||
}
|
||||
validation := &Validation{state: state}
|
||||
err := rule.Check(version, validation)
|
||||
state.active = false
|
||||
if err != nil && !hasErrors(state.diagnostics) {
|
||||
location, _ := nearestDocumentLocation(root, rule.Path)
|
||||
state.diagnostics = append(state.diagnostics, Diagnostic{
|
||||
Code: CodeVersionHookFailed, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "schema-version check failed", Primary: documentLocationPointer(location),
|
||||
})
|
||||
}
|
||||
return canonicalDiagnostics(state.diagnostics)
|
||||
}
|
||||
|
||||
func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
|
||||
if root == nil || !path.valid() {
|
||||
return nil, false
|
||||
}
|
||||
current := root
|
||||
for _, segment := range path.segments {
|
||||
switch current.Kind {
|
||||
case document.Mapping:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return nil, false
|
||||
}
|
||||
found := false
|
||||
for _, entry := range current.Mapping {
|
||||
if entry.Key == segment.key {
|
||||
current = entry.Value
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
case document.Sequence:
|
||||
index, ok := schemaIndex(segment)
|
||||
if !ok || index >= len(current.Sequence) {
|
||||
return nil, false
|
||||
}
|
||||
current = current.Sequence[index]
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func documentLocationPointer(location document.Location) *Location {
|
||||
if !locationKnown(location) {
|
||||
return nil
|
||||
}
|
||||
return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
|
||||
}
|
||||
|
||||
func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic {
|
||||
return Diagnostic{Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message}
|
||||
}
|
||||
|
||||
func diagnosticFromDecodeIssue(issue decodeIssue) Diagnostic {
|
||||
return Diagnostic{
|
||||
Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message,
|
||||
Primary: documentLocationPointer(issue.location),
|
||||
}
|
||||
}
|
||||
|
||||
func hasErrors(diagnostics []Diagnostic) bool {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.Severity == SeverityError {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
resolved.configuration = configuration
|
||||
// Publication detaches equal-valued validator replacements, preserving the
|
||||
// validated secret state, then finalizes metadata and sensitivity.
|
||||
return publishLoadResult(schema, options.Definition, resolved, secretResolution.references, &accumulator)
|
||||
}
|
||||
|
||||
88
load_input.go
Normal file
88
load_input.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package configkit
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
// assembledInput is the source/document handoff across schema and version checks.
|
||||
type assembledInput struct {
|
||||
parsed parsedSource
|
||||
document *document.Node
|
||||
composition *composedDocument
|
||||
sources []SourceInfo
|
||||
}
|
||||
|
||||
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
|
||||
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
|
||||
input := assembledInput{parsed: parsed, document: parsed.document}
|
||||
if len(diagnostics) != 0 {
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
return input, diagnostics
|
||||
}
|
||||
if options.Imports {
|
||||
input.composition, input.sources, diagnostics = loadImportedDocuments(source, options.Filesystem, parsed, options.Definition.Version, defaultImportLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
input.document = input.composition.root
|
||||
} else {
|
||||
input.sources = sourceInfos(parsed.source)
|
||||
if options.Filesystem != nil && !activeProfile(options.Profile) {
|
||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !options.Imports && input.document != nil {
|
||||
if diagnostics := disabledDirectiveDiagnostics(input.document, Path{}); len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) {
|
||||
if !activeProfile(options.Profile) && len(options.Overrides) == 0 {
|
||||
return input, nil
|
||||
}
|
||||
composition, diagnostics := compositionForInput(input)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
if activeProfile(options.Profile) {
|
||||
composition, input.sources, diagnostics = loadProfileDocument(
|
||||
source, options.Filesystem, input.parsed, composition, input.sources,
|
||||
options.Profile, options.Definition.Version, defaultImportLimits(),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
}
|
||||
if len(options.Overrides) != 0 {
|
||||
composition, input.sources, diagnostics = applyOverrides(
|
||||
composition, input.sources, options.Overrides, options.Definition.Version, defaultImportLimits(),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
}
|
||||
input.composition = composition
|
||||
input.document = composition.root
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// compositionForInput initializes one handoff for profiles and CLI, including
|
||||
// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets.
|
||||
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
|
||||
if input.composition != nil {
|
||||
return input.composition, nil
|
||||
}
|
||||
if input.document == nil {
|
||||
return &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}, nil
|
||||
}
|
||||
composition, diagnostics := composeDocuments(input.document)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
composition.inputBytes = input.parsed.bytes
|
||||
composition.inputNodes = countInputNodes(input.document)
|
||||
return composition, nil
|
||||
}
|
||||
@@ -159,23 +159,6 @@ func collectFieldInfo(node *document.Node, path Path, decoded decodedConfigurati
|
||||
}
|
||||
}
|
||||
|
||||
func indexDocument(node *document.Node, path Path, index map[string]*document.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
index[path.String()] = node
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
for _, entry := range node.Mapping {
|
||||
indexDocument(entry.Value, path.Key(entry.Key), index)
|
||||
}
|
||||
case document.Sequence:
|
||||
for position, child := range node.Sequence {
|
||||
indexDocument(child, path.Index(position), index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMetadata(metadata Metadata) Metadata {
|
||||
if metadata.fields == nil && metadata.history == nil {
|
||||
return Metadata{}
|
||||
@@ -184,6 +167,14 @@ func cloneMetadata(metadata Metadata) Metadata {
|
||||
fields: make(map[string]FieldInfo, len(metadata.fields)),
|
||||
history: make(map[string][]Change, len(metadata.history)),
|
||||
}
|
||||
cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths))
|
||||
for pointer, path := range metadata.effectivePaths {
|
||||
cloned.effectivePaths[pointer] = path.clone()
|
||||
}
|
||||
cloned.historicalPaths = make(map[string][]Path, len(metadata.historicalPaths))
|
||||
for pointer, paths := range metadata.historicalPaths {
|
||||
cloned.historicalPaths[pointer] = clonePaths(paths)
|
||||
}
|
||||
for path, info := range metadata.fields {
|
||||
cloned.fields[path] = cloneFieldInfo(info)
|
||||
}
|
||||
|
||||
119
load_result.go
Normal file
119
load_result.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
// resolvedLoad carries typed phase outputs needed for validation and publication;
|
||||
// it owns no source access, options, or long-lived schema state.
|
||||
type resolvedLoad[T any] struct {
|
||||
configuration T
|
||||
decoded decodedConfiguration
|
||||
metadata Metadata
|
||||
final *document.Node
|
||||
snapshots []resolutionSnapshot
|
||||
applications []historyApplication
|
||||
}
|
||||
|
||||
func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) {
|
||||
defaults := reflect.Zero(reflect.TypeFor[T]())
|
||||
if definition.Defaults != nil {
|
||||
defaults = reflect.ValueOf(definition.Defaults())
|
||||
}
|
||||
context := decodeContext{}
|
||||
if definition.Version != nil {
|
||||
context.versionPath = &definition.Version.Path
|
||||
}
|
||||
decoded, decodeIssues := decodeConfigurationWithContext(schema, defaults, input.document, context)
|
||||
if len(decodeIssues) != 0 {
|
||||
diagnostics := make([]Diagnostic, len(decodeIssues))
|
||||
for i, issue := range decodeIssues {
|
||||
diagnostics[i] = diagnosticFromDecodeIssue(issue)
|
||||
}
|
||||
return decodedConfiguration{}, diagnostics
|
||||
}
|
||||
if input.composition != nil {
|
||||
applyCompositionOrigins(&decoded, input.composition.origins)
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, decoded decodedConfiguration) (resolvedLoad[T], []Diagnostic) {
|
||||
var diagnostics []Diagnostic
|
||||
configuration := decoded.value.Interface().(T)
|
||||
metadata := metadataFromDecoded(decoded)
|
||||
if input.composition != nil {
|
||||
applyOverlayMetadata(&metadata, input.composition.overlays)
|
||||
}
|
||||
final := decoded.final
|
||||
var resolutionSnapshots []resolutionSnapshot
|
||||
if definition.Resolve != nil {
|
||||
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document)
|
||||
diagnostics = append(diagnostics, resolved.diagnostics...)
|
||||
if hasErrors(resolved.diagnostics) {
|
||||
return resolvedLoad[T]{}, diagnostics
|
||||
}
|
||||
final = resolved.final
|
||||
resolutionSnapshots = resolved.snapshots
|
||||
applyResolutionMetadata(&metadata, resolved)
|
||||
}
|
||||
applications := []historyApplication(nil)
|
||||
if input.composition != nil {
|
||||
applications = cloneHistoryApplications(input.composition.applications)
|
||||
} else if input.document != nil {
|
||||
applications = []historyApplication{{layer: LayerBase, value: input.document}}
|
||||
}
|
||||
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications}, diagnostics
|
||||
}
|
||||
|
||||
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
|
||||
detached, issue := cloneConfigurationWithMode(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), mode)
|
||||
if issue != nil {
|
||||
diagnostic := diagnosticFromDecodeIssue(decodeIssueFromProjection(issue))
|
||||
var zero T
|
||||
return zero, &diagnostic
|
||||
}
|
||||
return detached.Interface().(T), nil
|
||||
}
|
||||
|
||||
func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], references map[string]string, accumulator *diagnosticAccumulator) (*Result[T], error) {
|
||||
// A read-only validator can replace a value with an equal borrowed object.
|
||||
// Detach without re-reading the environment or discarding validated state.
|
||||
configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets)
|
||||
if issue != nil {
|
||||
accumulator.add(*issue)
|
||||
return nil, accumulator.loadError()
|
||||
}
|
||||
decoded, metadata := resolved.decoded, resolved.metadata
|
||||
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
|
||||
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
|
||||
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
|
||||
return &Result[T]{
|
||||
Config: configuration,
|
||||
metadata: metadata,
|
||||
sources: cloneSourceInfos(accumulator.sources),
|
||||
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
|
||||
baseline: decoded.baseline,
|
||||
input: decoded.final,
|
||||
inputOrigins: cloneOriginIndex(decoded.origins),
|
||||
final: final,
|
||||
resolution: resolutionSnapshots,
|
||||
applications: applications,
|
||||
secrets: secrets,
|
||||
secretReferences: cloneSecretReferences(references),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic {
|
||||
return Diagnostic{Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message}
|
||||
}
|
||||
|
||||
func diagnosticFromDecodeIssue(issue decodeIssue) Diagnostic {
|
||||
return Diagnostic{
|
||||
Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message,
|
||||
Primary: documentLocationPointer(issue.location),
|
||||
}
|
||||
}
|
||||
317
load_test.go
317
load_test.go
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
|
||||
@@ -72,7 +73,9 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
||||
{name: "no source absent", source: NoSource(), want: Version{}},
|
||||
{name: "null", source: Bytes("null.yml", []byte("schema_version: null\n")), want: Version{Present: true, Null: true}, wantLine: 1},
|
||||
{name: "string", source: Bytes("string.yml", []byte("schema_version: app.v1\n")), want: Version{Present: true, Text: "app.v1"}, wantLine: 1},
|
||||
{name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), wantErr: CodeVersionType},
|
||||
{name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), want: Version{Present: true, Text: "1"}, wantLine: 1},
|
||||
{name: "boolean", source: Bytes("boolean.yml", []byte("schema_version: true\n")), wantErr: CodeVersionType},
|
||||
{name: "sequence", source: Bytes("sequence.yml", []byte("schema_version: []\n")), wantErr: CodeVersionType},
|
||||
{name: "returned error", source: Bytes("return.yml", []byte("{}\n")), wantErr: CodeVersionHookFailed, returnOnly: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -103,7 +106,7 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if test.wantErr == CodeVersionType && called {
|
||||
t.Fatal("checker ran for a non-string version")
|
||||
t.Fatal("checker ran for an unsupported version kind")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -124,6 +127,312 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type stringConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
type pointerConfig struct {
|
||||
Version *string `yaml:"version"`
|
||||
}
|
||||
versionPath := Keys("version")
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
source string
|
||||
load func(Source, *VersionRule) (string, error)
|
||||
}{
|
||||
{name: "integer into string", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Config.Version, nil
|
||||
}},
|
||||
{name: "string into string", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Config.Version, nil
|
||||
}},
|
||||
{name: "integer into pointer", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Config.Version == nil {
|
||||
t.Fatal("version pointer is nil")
|
||||
}
|
||||
return *result.Config.Version, nil
|
||||
}},
|
||||
{name: "string into pointer", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Config.Version == nil {
|
||||
t.Fatal("version pointer is nil")
|
||||
}
|
||||
return *result.Config.Version, nil
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var checked Version
|
||||
rule := &VersionRule{Path: versionPath, Check: func(version Version, _ *Validation) error {
|
||||
checked = version
|
||||
return nil
|
||||
}}
|
||||
got, err := test.load(Bytes("version.yml", []byte(test.source)), rule)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "4" || checked != (Version{Present: true, Text: "4"}) {
|
||||
t.Fatalf("value = %q, checked = %#v", got, checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[stringConfig]{}); result != nil {
|
||||
t.Fatalf("load without rule = %#v, %v", result, err)
|
||||
} else {
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeDecodeType {
|
||||
t.Fatalf("load without rule error = %v", err)
|
||||
}
|
||||
}
|
||||
type unrelatedConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
Label string `yaml:"label"`
|
||||
}
|
||||
rule := &VersionRule{Path: versionPath, Check: func(Version, *Validation) error { return nil }}
|
||||
if result, err := Load(Bytes("version.yml", []byte("version: 4\nlabel: 5\n")), Options[unrelatedConfig]{Definition: Definition[unrelatedConfig]{Version: rule}}); result != nil {
|
||||
t.Fatalf("unrelated coercion = %#v, %v", result, err)
|
||||
} else {
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeDecodeType || loadError.Diagnostics()[0].Path.String() != "/label" {
|
||||
t.Fatalf("unrelated coercion error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{name: "zero", value: "0", want: "0"},
|
||||
{name: "negative", value: "-7", want: "-7"},
|
||||
{name: "negative zero", value: "-0", want: "-0"},
|
||||
{name: "beyond int64", value: "123456789012345678901234567890", want: "123456789012345678901234567890"},
|
||||
{name: "empty string", value: `""`, want: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var checked Version
|
||||
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, _ *Validation) error {
|
||||
checked = version
|
||||
return nil
|
||||
}}
|
||||
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Version != test.want || checked.Text != test.want || !checked.Present || checked.Null {
|
||||
t.Fatalf("configuration = %#v, checked = %#v", result.Config, checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "fraction", value: "4.0"},
|
||||
{name: "exponent", value: "4e0"},
|
||||
{name: "boolean", value: "true"},
|
||||
{name: "mapping", value: "{}"},
|
||||
{name: "sequence", value: "[]"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
called := false
|
||||
rule := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error {
|
||||
called = true
|
||||
return nil
|
||||
}}
|
||||
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||
if result != nil || called {
|
||||
t.Fatalf("result = %#v, checker called = %v", result, called)
|
||||
}
|
||||
requireDiagnostic(t, err, CodeVersionType, "/version")
|
||||
})
|
||||
}
|
||||
|
||||
type namedVersion string
|
||||
type nested struct {
|
||||
Versions map[string]**namedVersion `yaml:"versions"`
|
||||
Sibling namedVersion `yaml:"sibling"`
|
||||
}
|
||||
parsed, err := ParsePath("/versions/0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := &VersionRule{Path: parsed, Check: func(version Version, _ *Validation) error {
|
||||
if version.Text != "4" {
|
||||
t.Errorf("version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
result, err := Load(Bytes("nested.yml", []byte("versions:\n \"0\": 4\nsibling: quoted\n")), Options[nested]{Definition: Definition[nested]{Version: rule}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Versions["0"] == nil || *result.Config.Versions["0"] == nil || **result.Config.Versions["0"] != "4" || result.Config.Sibling != "quoted" {
|
||||
t.Fatalf("nested configuration = %#v", result.Config)
|
||||
}
|
||||
type sequenceConfig struct {
|
||||
Versions []namedVersion `yaml:"versions"`
|
||||
}
|
||||
sequenceResult, err := Load(Bytes("sequence.yml", []byte("versions: [6]\n")), Options[sequenceConfig]{Definition: Definition[sequenceConfig]{Version: &VersionRule{Path: parsed, Check: func(Version, *Validation) error { return nil }}}})
|
||||
if err != nil || len(sequenceResult.Config.Versions) != 1 || sequenceResult.Config.Versions[0] != "6" {
|
||||
t.Fatalf("sequence configuration = %#v, %v", sequenceResult, err)
|
||||
}
|
||||
|
||||
type escaped struct {
|
||||
Versions map[string]namedVersion `yaml:"versions"`
|
||||
}
|
||||
escapedPath, err := ParsePath("/versions/a~1b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
escapedResult, err := Load(Bytes("escaped.yml", []byte("versions:\n a/b: 9\n")), Options[escaped]{Definition: Definition[escaped]{Version: &VersionRule{Path: escapedPath, Check: func(Version, *Validation) error { return nil }}}})
|
||||
if err != nil || escapedResult.Config.Versions["a/b"] != "9" {
|
||||
t.Fatalf("escaped configuration = %#v, %v", escapedResult, err)
|
||||
}
|
||||
|
||||
siblingRule := &VersionRule{Path: Keys("versions", "target"), Check: func(Version, *Validation) error { return nil }}
|
||||
if result, err := Load(Bytes("sibling.yml", []byte("versions:\n target: 4\n other: 5\n")), Options[escaped]{Definition: Definition[escaped]{Version: siblingRule}}); result != nil {
|
||||
t.Fatalf("same-type sibling result = %#v, %v", result, err)
|
||||
} else {
|
||||
requireDiagnostic(t, err, CodeDecodeType, "/versions/other")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
check := func(Version, *Validation) error { return nil }
|
||||
t.Run("integer destination", func(t *testing.T) {
|
||||
type config struct {
|
||||
Version int8 `yaml:"version"`
|
||||
}
|
||||
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
if err != nil || result.Config.Version != 4 {
|
||||
t.Fatalf("result = %#v, %v", result, err)
|
||||
}
|
||||
result, err = Load(Bytes("version.yml", []byte("version: 999\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
if result != nil {
|
||||
t.Fatalf("range result = %#v", result)
|
||||
}
|
||||
requireDiagnostic(t, err, CodeDecodeRange, "/version")
|
||||
result, err = Load(Bytes("version.yml", []byte("version: \"4\"\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
if result != nil {
|
||||
t.Fatalf("quoted integer result = %#v", result)
|
||||
}
|
||||
requireDiagnostic(t, err, CodeDecodeType, "/version")
|
||||
})
|
||||
|
||||
t.Run("dynamic destination", func(t *testing.T) {
|
||||
type config struct {
|
||||
Version any `yaml:"version"`
|
||||
}
|
||||
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
if err != nil || result.Config.Version != int64(4) {
|
||||
t.Fatalf("result = %#v, %v", result, err)
|
||||
}
|
||||
result, err = Load(Bytes("version.yml", []byte("version: 123456789012345678901234567890\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
if result != nil {
|
||||
t.Fatalf("range result = %#v", result)
|
||||
}
|
||||
requireDiagnostic(t, err, CodeDecodeRange, "/version")
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
load func() error
|
||||
}{
|
||||
{name: "text codec", load: func() error {
|
||||
type config struct {
|
||||
Version validatedText `yaml:"version"`
|
||||
}
|
||||
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
return err
|
||||
}},
|
||||
{name: "duration", load: func() error {
|
||||
type config struct {
|
||||
Version time.Duration `yaml:"version"`
|
||||
}
|
||||
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
return err
|
||||
}},
|
||||
{name: "secret", load: func() error {
|
||||
type config struct {
|
||||
Version Secret `yaml:"version"`
|
||||
}
|
||||
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||
return err
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
requireDiagnostic(t, test.load(), CodeDecodeType, "/version")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadVersionPolicyStopsLaterHooks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
var defaultsCalled, resolutionCalled, validationCalled bool
|
||||
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, validation *Validation) error {
|
||||
if version.Text != "4" {
|
||||
t.Errorf("version = %#v", version)
|
||||
}
|
||||
validation.Error(Keys("version"), "unsupported configuration version")
|
||||
return nil
|
||||
}}
|
||||
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{
|
||||
Defaults: func() config { defaultsCalled = true; return config{} },
|
||||
Version: rule,
|
||||
Resolve: func(*config, *Resolution) error { resolutionCalled = true; return nil },
|
||||
Validate: func(*config, *Validation) { validationCalled = true },
|
||||
}})
|
||||
if result != nil || defaultsCalled || resolutionCalled || validationCalled {
|
||||
t.Fatalf("result = %#v, hooks = %v/%v/%v", result, defaultsCalled, resolutionCalled, validationCalled)
|
||||
}
|
||||
requireDiagnostic(t, err, CodeVersionError, "/version")
|
||||
}
|
||||
|
||||
func requireDiagnostic(t *testing.T, err error, code Code, path string) {
|
||||
t.Helper()
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != code || diagnostics[0].Path.String() != path || diagnostics[0].Primary == nil {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -240,7 +549,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n _configkit: {}\n")))
|
||||
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n configkit: {}\n")))
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, err = %#v, %v", result, err)
|
||||
}
|
||||
@@ -249,7 +558,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/_configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
|
||||
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
|
||||
t.Fatalf("diagnostic = %#v", diagnostic)
|
||||
}
|
||||
}
|
||||
|
||||
101
metadata.go
101
metadata.go
@@ -1,16 +1,24 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
// Metadata provides detached effective presence/origin and chronological history
|
||||
// queries for a load. Its zero value contains no paths.
|
||||
type Metadata struct {
|
||||
fields map[string]FieldInfo
|
||||
history map[string][]Change
|
||||
effectivePaths map[string]Path
|
||||
historicalPaths map[string][]Path
|
||||
fields map[string]FieldInfo
|
||||
history map[string][]Change
|
||||
}
|
||||
|
||||
// Lookup returns information for an effective path. Unknown and invalid paths
|
||||
// return false.
|
||||
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
|
||||
if !path.valid() {
|
||||
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) {
|
||||
return FieldInfo{}, false
|
||||
}
|
||||
info, ok := m.fields[path.String()]
|
||||
@@ -32,7 +40,7 @@ func (m Metadata) IsExplicit(path Path) bool {
|
||||
// Removed historical paths can have history even though Lookup returns false.
|
||||
// Unknown and invalid paths return nil.
|
||||
func (m Metadata) History(path Path) []Change {
|
||||
if !path.valid() {
|
||||
if !m.hasHistoricalPath(path) {
|
||||
return nil
|
||||
}
|
||||
history, ok := m.history[path.String()]
|
||||
@@ -45,3 +53,88 @@ func (m Metadata) History(path Path) []Change {
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// HistoricalPaths returns the paths observed during loading, including effective
|
||||
// paths and paths removed by overlays or recorded resolution. It includes default
|
||||
// paths and the root, but not paths that never existed. The zero Metadata returns
|
||||
// an empty slice.
|
||||
//
|
||||
// Paths are detached, unique by canonical JSON Pointer, and sorted by that
|
||||
// spelling. Like ParsePath results, numeric tokens are resolved contextually;
|
||||
// one pointer can identify a mapping key in an earlier layer and a sequence index
|
||||
// in a later one. Use History to inspect definitions and Lookup to check whether
|
||||
// a path remains effective. Enumeration exposes no configuration values and does
|
||||
// not perform another load or resolve secrets.
|
||||
func (m Metadata) HistoricalPaths() []Path {
|
||||
pointers := make([]string, 0, len(m.historicalPaths))
|
||||
for pointer := range m.historicalPaths {
|
||||
pointers = append(pointers, pointer)
|
||||
}
|
||||
sort.Strings(pointers)
|
||||
paths := make([]Path, 0, len(pointers))
|
||||
for _, pointer := range pointers {
|
||||
// The index contains only canonical pointers produced by Path.String.
|
||||
path, _ := ParsePath(pointer)
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
|
||||
// the query. Candidate paths are constructed from actual document containers.
|
||||
func matchesPathShape(query, candidate Path) bool {
|
||||
if !query.valid() || query.String() != candidate.String() || len(query.segments) != len(candidate.segments) {
|
||||
return false
|
||||
}
|
||||
for i, segment := range query.segments {
|
||||
actual := candidate.segments[i]
|
||||
if actual.kind == pathSegmentIndex {
|
||||
index, ok := schemaIndex(segment)
|
||||
if !ok || index != actual.index {
|
||||
return false
|
||||
}
|
||||
} else if segment.kind == pathSegmentIndex {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m Metadata) hasHistoricalPath(path Path) bool {
|
||||
if !path.valid() {
|
||||
return false
|
||||
}
|
||||
for _, candidate := range m.historicalPaths[path.String()] {
|
||||
if matchesPathShape(path, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
|
||||
m.effectivePaths = make(map[string]Path)
|
||||
m.historicalPaths = make(map[string][]Path)
|
||||
walkDocument(final, Path{}, func(path Path, _ *document.Node) { m.effectivePaths[path.String()] = path.clone() })
|
||||
add := func(path Path, _ *document.Node) {
|
||||
pointer := path.String()
|
||||
for _, prior := range m.historicalPaths[pointer] {
|
||||
if matchesPathShape(path, prior) {
|
||||
return
|
||||
}
|
||||
}
|
||||
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
|
||||
}
|
||||
walkDocument(final, Path{}, add)
|
||||
for _, tree := range earlier {
|
||||
walkDocument(tree, Path{}, add)
|
||||
}
|
||||
for _, application := range applications {
|
||||
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
|
||||
prefix := Path{}
|
||||
for _, segment := range application.path.segments {
|
||||
prefix = prefix.Key(segment.key)
|
||||
}
|
||||
walkDocument(application.value, prefix, add)
|
||||
}
|
||||
}
|
||||
|
||||
135
metadata_test.go
135
metadata_test.go
@@ -1,12 +1,103 @@
|
||||
package configkit
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMetadataHistoricalPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
result, err := Load[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{
|
||||
Overrides: []Override{
|
||||
{Path: Keys("retired"), Value: nil, Flag: "--retired"},
|
||||
{Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
paths := metadata.HistoricalPaths()
|
||||
var got []string
|
||||
for _, path := range paths {
|
||||
got = append(got, path.String())
|
||||
if len(metadata.History(path)) == 0 {
|
||||
t.Fatalf("enumerated path %s has no history", path)
|
||||
}
|
||||
}
|
||||
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("historical paths = %v, want %v", got, want)
|
||||
}
|
||||
if _, ok := metadata.Lookup(paths[4]); ok {
|
||||
t.Fatal("removed descendant remains effective")
|
||||
}
|
||||
if _, ok := metadata.Lookup(paths[6]); !ok {
|
||||
t.Fatal("enumerated numeric path cannot query the new sequence shape")
|
||||
}
|
||||
// Returned slices and nested paths must not alias retained metadata.
|
||||
paths[1].segments[0].key = "mutated"
|
||||
paths[2] = Keys("replacement")
|
||||
if again := metadata.HistoricalPaths(); again[1].String() != want[1] || again[2].String() != want[2] {
|
||||
t.Fatal("enumeration returned aliased paths")
|
||||
}
|
||||
if len((Metadata{}).HistoricalPaths()) != 0 {
|
||||
t.Fatal("zero metadata contains paths")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *testing.T) {
|
||||
t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_KEY", "")
|
||||
t.Setenv("CONFIGKIT_HISTORY_TEST_KEY", "synthetic-test-key")
|
||||
type config struct {
|
||||
Credential *Secret `yaml:"credential"`
|
||||
Definitions map[string]map[string]string `yaml:"definitions"`
|
||||
}
|
||||
for _, credential := range []string{"CONFIGKIT_HISTORY_TEST_KEY", "null"} {
|
||||
t.Run(credential, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := writeImportFile(t, dir, "main.yml", "configkit:\n imports: [{path: first.yml}, {path: second.yml}]\ncredential: CONFIGKIT_HISTORY_UNUSED_PROD_KEY\n")
|
||||
writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n")
|
||||
writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n")
|
||||
writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n")
|
||||
result, err := Load[config](File(root), Options[config]{Imports: true, Profile: "testing"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
found := false
|
||||
for _, path := range metadata.HistoricalPaths() {
|
||||
if path.String() != "/definitions/recap" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
owners := map[string]bool{}
|
||||
for _, change := range metadata.History(path) {
|
||||
if change.Origin.Layer == LayerBase {
|
||||
for _, location := range change.Origin.Locations {
|
||||
owners[location.SourceID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(owners) != 2 {
|
||||
t.Fatalf("hidden declaration owners = %v", owners)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("hidden base declaration was not discoverable")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := Keys("service", "workers")
|
||||
metadata := Metadata{
|
||||
effectivePaths: map[string]Path{path.String(): path},
|
||||
historicalPaths: map[string][]Path{path.String(): {path}},
|
||||
fields: map[string]FieldInfo{
|
||||
path.String(): {
|
||||
Explicit: true,
|
||||
@@ -56,3 +147,45 @@ func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
|
||||
t.Fatal("invalid path produced metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) {
|
||||
result, err := Load[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []Path{Keys("items", "0"), Keys("m").Index(0), Keys("x", "missing"), Keys("items").Index(8)} {
|
||||
if _, ok := result.Metadata().Lookup(path); ok {
|
||||
t.Fatalf("invalid lookup %s", path)
|
||||
}
|
||||
if result.Metadata().History(path) != nil {
|
||||
t.Fatalf("invalid history %s", path)
|
||||
}
|
||||
if _, ok := result.Explain(path); ok {
|
||||
t.Fatalf("invalid explanation %s", path)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := result.WriteExplanation(&out, path); err == nil || out.Len() != 0 {
|
||||
t.Fatalf("invalid path wrote output: %s", path)
|
||||
}
|
||||
}
|
||||
for _, pointer := range []string{"/items/0", "/m/0"} {
|
||||
path, _ := ParsePath(pointer)
|
||||
if _, ok := result.Metadata().Lookup(path); !ok {
|
||||
t.Fatal("parsed path lost lookup")
|
||||
}
|
||||
if _, ok := result.Explain(path); !ok {
|
||||
t.Fatal("parsed path lost explanation")
|
||||
}
|
||||
}
|
||||
retired := Keys("retired", "child")
|
||||
if _, ok := result.Metadata().Lookup(retired); ok {
|
||||
t.Fatal("removed path remains effective")
|
||||
}
|
||||
if len(result.Metadata().History(retired)) == 0 {
|
||||
t.Fatal("removed history missing")
|
||||
}
|
||||
x, ok := result.Explain(retired)
|
||||
if !ok || x.Effective.Present || len(x.History) == 0 {
|
||||
t.Fatal("removed explanation missing")
|
||||
}
|
||||
}
|
||||
|
||||
41
options.go
Normal file
41
options.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package configkit
|
||||
|
||||
func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) {
|
||||
if len(supplied) > 1 {
|
||||
return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}
|
||||
}
|
||||
var options Options[T]
|
||||
if len(supplied) == 1 {
|
||||
options = supplied[0]
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
definition := options.Definition
|
||||
if !validProfileName(options.Profile) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen"))
|
||||
}
|
||||
if options.Filesystem != nil {
|
||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
||||
}
|
||||
if source.kind == sourceNone && options.Imports {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
|
||||
}
|
||||
if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile"))
|
||||
}
|
||||
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context"))
|
||||
}
|
||||
if definition.Version != nil {
|
||||
if !definition.Version.Path.valid() {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))
|
||||
}
|
||||
if definition.Version.Check == nil {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
|
||||
}
|
||||
}
|
||||
return options, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
func optionDiagnostic(code Code, message string) Diagnostic {
|
||||
return Diagnostic{Code: code, Severity: SeverityError, Message: message}
|
||||
}
|
||||
15
overlay.go
15
overlay.go
@@ -10,8 +10,6 @@ import (
|
||||
type overlayRecord struct {
|
||||
change Change
|
||||
displaced []displacedDefinition
|
||||
before *document.Node
|
||||
after *document.Node
|
||||
}
|
||||
|
||||
type displacedDefinition struct {
|
||||
@@ -26,15 +24,7 @@ func overlayDocuments(base *composedDocument, overlay *document.Node) (*composed
|
||||
Message: "profile overlay requires mapping document roots",
|
||||
}}
|
||||
}
|
||||
result := &composedDocument{
|
||||
root: document.Clone(base.root),
|
||||
claims: cloneCompositionClaims(base.claims),
|
||||
origins: cloneOriginIndex(base.origins),
|
||||
overlays: cloneOverlayRecords(base.overlays),
|
||||
applications: cloneHistoryApplications(base.applications),
|
||||
inputBytes: base.inputBytes,
|
||||
inputNodes: base.inputNodes,
|
||||
}
|
||||
result := cloneComposedDocument(base)
|
||||
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
|
||||
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
|
||||
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
|
||||
@@ -73,7 +63,6 @@ func mergeOverlayMappings(result *composedDocument, target, overlay *document.No
|
||||
Previous: previousOrigins(previous),
|
||||
},
|
||||
displaced: displaced,
|
||||
before: document.Clone(existing), after: document.Clone(incoming.Value),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -161,8 +150,6 @@ func cloneOverlayRecords(records []overlayRecord) []overlayRecord {
|
||||
cloned := make([]overlayRecord, len(records))
|
||||
for index, record := range records {
|
||||
cloned[index].change = cloneChange(record.change)
|
||||
cloned[index].before = document.Clone(record.before)
|
||||
cloned[index].after = document.Clone(record.after)
|
||||
cloned[index].displaced = make([]displacedDefinition, len(record.displaced))
|
||||
for displacedIndex, displaced := range record.displaced {
|
||||
cloned[index].displaced[displacedIndex] = displacedDefinition{
|
||||
|
||||
247
override.go
247
override.go
@@ -1,9 +1,6 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -22,17 +19,7 @@ type preparedOverride struct {
|
||||
nodes int
|
||||
}
|
||||
|
||||
type overrideValueBuilder struct {
|
||||
sourceID string
|
||||
active map[valueVisit]bool
|
||||
bytes int64
|
||||
nodes int
|
||||
}
|
||||
|
||||
func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
if len(overrides) == 0 {
|
||||
return base, cloneSourceInfos(sources), nil
|
||||
}
|
||||
func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) ([]preparedOverride, []SourceInfo, []Diagnostic) {
|
||||
if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources {
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{{
|
||||
Code: CodeOverrideLimitExceeded, Severity: SeverityError,
|
||||
@@ -147,13 +134,7 @@ func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Ov
|
||||
return nil, allSources, canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
result := cloneComposedDocument(base)
|
||||
for _, override := range prepared {
|
||||
applyPreparedOverride(result, override)
|
||||
}
|
||||
result.inputBytes += usedBytes
|
||||
result.inputNodes += usedNodes
|
||||
return result, allSources, nil
|
||||
return prepared, allSources, nil
|
||||
}
|
||||
|
||||
func validOverrideLabel(label string) bool {
|
||||
@@ -207,7 +188,7 @@ func overlappingOverride(previous []Override, valid []bool, path Path) int {
|
||||
|
||||
func pathContainsDirective(path Path) bool {
|
||||
for _, segment := range path.segments {
|
||||
if segment.key == "_configkit" {
|
||||
if segment.key == directiveKey {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -224,7 +205,7 @@ func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
|
||||
if node.Kind == document.Mapping {
|
||||
for _, entry := range node.Mapping {
|
||||
child := path.Key(entry.Key)
|
||||
if entry.Key == "_configkit" {
|
||||
if entry.Key == directiveKey {
|
||||
return child, true
|
||||
}
|
||||
if found, ok := overrideDirectivePath(entry.Value, child); ok {
|
||||
@@ -240,223 +221,3 @@ func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
|
||||
}
|
||||
return Path{}, false
|
||||
}
|
||||
|
||||
func (b *overrideValueBuilder) build(value any, path Path, depth int) (*document.Node, *Diagnostic) {
|
||||
if depth > maxDocumentDepth {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the depth limit")
|
||||
}
|
||||
b.nodes++
|
||||
if b.nodes > maxDocumentNodes {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||
}
|
||||
location := document.Location{SourceID: b.sourceID}
|
||||
switch value := value.(type) {
|
||||
case nil:
|
||||
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||
case string:
|
||||
if !b.addBytes(len(value)) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the record string-byte limit")
|
||||
}
|
||||
return &document.Node{Kind: document.String, Text: value, Location: location}, nil
|
||||
case bool:
|
||||
return &document.Node{Kind: document.Boolean, Text: strconv.FormatBool(value), Location: location}, nil
|
||||
case int:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int8:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int16:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int32:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int64:
|
||||
return overrideNumber(strconv.FormatInt(value, 10), location), nil
|
||||
case uint:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint8:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint16:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint32:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint64:
|
||||
return overrideNumber(strconv.FormatUint(value, 10), location), nil
|
||||
case float32:
|
||||
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
|
||||
}
|
||||
return overrideNumber(strconv.FormatFloat(float64(value), 'g', -1, 32), location), nil
|
||||
case float64:
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
|
||||
}
|
||||
return overrideNumber(strconv.FormatFloat(value, 'g', -1, 64), location), nil
|
||||
case []any:
|
||||
if value == nil {
|
||||
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||
}
|
||||
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
defer leave()
|
||||
if len(value) > maxDocumentNodes-b.nodes {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||
}
|
||||
node := &document.Node{Kind: document.Sequence, Location: location, Sequence: make([]*document.Node, len(value))}
|
||||
for index, child := range value {
|
||||
built, diagnostic := b.build(child, path.Index(index), depth+1)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
node.Sequence[index] = built
|
||||
}
|
||||
return node, nil
|
||||
case map[string]any:
|
||||
if value == nil {
|
||||
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||
}
|
||||
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
defer leave()
|
||||
if len(value) > (maxDocumentNodes-b.nodes)/2 {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||
}
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
node := &document.Node{Kind: document.Mapping, Location: location, Mapping: make([]document.Entry, 0, len(keys))}
|
||||
for _, key := range keys {
|
||||
b.nodes++ // mapping key
|
||||
if !b.addBytes(len(key)) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path.Key(key), "configuration override exceeds the record string-byte limit")
|
||||
}
|
||||
built, diagnostic := b.build(value[key], path.Key(key), depth+1)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
node.Mapping = append(node.Mapping, document.Entry{Key: key, KeyLocation: location, ValueLocation: location, Value: built})
|
||||
}
|
||||
return node, nil
|
||||
default:
|
||||
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override value has an unsupported type")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *overrideValueBuilder) addBytes(count int) bool {
|
||||
if count < 0 || int64(count) > maxSourceBytes-b.bytes {
|
||||
return false
|
||||
}
|
||||
b.bytes += int64(count)
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *overrideValueBuilder) enter(value reflect.Value, path Path) (func(), *Diagnostic) {
|
||||
visit, ok := visitFor(value)
|
||||
if !ok {
|
||||
return func() {}, nil
|
||||
}
|
||||
if b.active[visit] {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override values must not contain cycles")
|
||||
}
|
||||
b.active[visit] = true
|
||||
return func() { delete(b.active, visit) }, nil
|
||||
}
|
||||
|
||||
func overrideValueDiagnostic(code Code, path Path, message string) *Diagnostic {
|
||||
return &Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
|
||||
}
|
||||
|
||||
func overrideNumber(text string, location document.Location) *document.Node {
|
||||
return &document.Node{Kind: document.Number, Text: text, Location: location}
|
||||
}
|
||||
|
||||
func cloneComposedDocument(base *composedDocument) *composedDocument {
|
||||
return &composedDocument{
|
||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||
applications: cloneHistoryApplications(base.applications),
|
||||
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
|
||||
}
|
||||
}
|
||||
|
||||
func applyPreparedOverride(result *composedDocument, override preparedOverride) {
|
||||
location := document.Location{SourceID: override.source.ID}
|
||||
mergeOverlayOrigin(result.origins, Path{}, location, LayerCLI)
|
||||
current := result.root
|
||||
currentPath := Path{}
|
||||
for index, segment := range override.path.segments {
|
||||
path := currentPath.Key(segment.key)
|
||||
position := documentEntryIndex(current.Mapping, segment.key)
|
||||
if index < len(override.path.segments)-1 {
|
||||
if position < 0 {
|
||||
child := &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{}}
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: child})
|
||||
collectOverlayOrigins(result.origins, child, path, LayerCLI)
|
||||
current = child
|
||||
} else {
|
||||
current = current.Mapping[position].Value
|
||||
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
|
||||
}
|
||||
currentPath = path
|
||||
continue
|
||||
}
|
||||
|
||||
previous := cloneOrigin(result.origins[path.String()])
|
||||
var displaced []displacedDefinition
|
||||
var before *document.Node
|
||||
if position < 0 {
|
||||
incoming := document.Clone(override.value)
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: incoming})
|
||||
collectOverlayOrigins(result.origins, incoming, path, LayerCLI)
|
||||
} else {
|
||||
existing := current.Mapping[position].Value
|
||||
before = document.Clone(existing)
|
||||
displaced = affectedOverrideOrigins(existing, override.value, result.origins, path)
|
||||
if existing.Kind == document.Mapping && override.value.Kind == document.Mapping {
|
||||
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
|
||||
mergeOverlayMappings(result, existing, override.value, path, LayerCLI, false)
|
||||
} else {
|
||||
deleteOriginSubtree(result.origins, path)
|
||||
current.Mapping[position] = document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: document.Clone(override.value)}
|
||||
collectOverlayOrigins(result.origins, current.Mapping[position].Value, path, LayerCLI)
|
||||
}
|
||||
}
|
||||
position = documentEntryIndex(current.Mapping, segment.key)
|
||||
result.overlays = append(result.overlays, overlayRecord{change: Change{
|
||||
Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous),
|
||||
}, displaced: displaced, before: before, after: document.Clone(current.Mapping[position].Value)})
|
||||
result.applications = append(result.applications, historyApplication{
|
||||
layer: LayerCLI, path: path.clone(), value: override.value,
|
||||
origin: overlayOrigin(LayerCLI, location),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
|
||||
displaced := []displacedDefinition{{path: path.clone(), origin: cloneOrigin(origins[path.String()])}}
|
||||
if existing.Kind == document.Mapping && incoming.Kind == document.Mapping {
|
||||
for _, entry := range incoming.Mapping {
|
||||
position := documentEntryIndex(existing.Mapping, entry.Key)
|
||||
if position < 0 {
|
||||
continue
|
||||
}
|
||||
childPath := path.Key(entry.Key)
|
||||
childExisting := existing.Mapping[position].Value
|
||||
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
|
||||
displaced = append(displaced, affectedOverrideOrigins(childExisting, entry.Value, origins, childPath)...)
|
||||
} else {
|
||||
displaced = append(displaced, displacedOrigins(childExisting, origins, childPath)...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
displaced = displacedOrigins(existing, origins, path)
|
||||
}
|
||||
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
|
||||
return strings.Compare(left.path.String(), right.path.String())
|
||||
})
|
||||
return displaced
|
||||
}
|
||||
|
||||
100
override_apply.go
Normal file
100
override_apply.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
||||
if len(overrides) == 0 {
|
||||
return base, cloneSourceInfos(sources), nil
|
||||
}
|
||||
prepared, allSources, diagnostics := prepareOverrides(base, sources, overrides, rule, limits)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, allSources, diagnostics
|
||||
}
|
||||
result := cloneComposedDocument(base)
|
||||
for _, override := range prepared {
|
||||
applyPreparedOverride(result, override)
|
||||
result.inputBytes += override.bytes
|
||||
result.inputNodes += override.nodes
|
||||
}
|
||||
return result, allSources, nil
|
||||
}
|
||||
|
||||
func applyPreparedOverride(result *composedDocument, override preparedOverride) {
|
||||
location := document.Location{SourceID: override.source.ID}
|
||||
mergeOverlayOrigin(result.origins, Path{}, location, LayerCLI)
|
||||
current := result.root
|
||||
currentPath := Path{}
|
||||
for index, segment := range override.path.segments {
|
||||
path := currentPath.Key(segment.key)
|
||||
position := documentEntryIndex(current.Mapping, segment.key)
|
||||
if index < len(override.path.segments)-1 {
|
||||
if position < 0 {
|
||||
child := &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{}}
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: child})
|
||||
collectOverlayOrigins(result.origins, child, path, LayerCLI)
|
||||
current = child
|
||||
} else {
|
||||
current = current.Mapping[position].Value
|
||||
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
|
||||
}
|
||||
currentPath = path
|
||||
continue
|
||||
}
|
||||
|
||||
previous := cloneOrigin(result.origins[path.String()])
|
||||
var displaced []displacedDefinition
|
||||
if position < 0 {
|
||||
incoming := document.Clone(override.value)
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: incoming})
|
||||
collectOverlayOrigins(result.origins, incoming, path, LayerCLI)
|
||||
} else {
|
||||
existing := current.Mapping[position].Value
|
||||
displaced = affectedOverrideOrigins(existing, override.value, result.origins, path)
|
||||
if existing.Kind == document.Mapping && override.value.Kind == document.Mapping {
|
||||
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
|
||||
mergeOverlayMappings(result, existing, override.value, path, LayerCLI, false)
|
||||
} else {
|
||||
deleteOriginSubtree(result.origins, path)
|
||||
current.Mapping[position] = document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: document.Clone(override.value)}
|
||||
collectOverlayOrigins(result.origins, current.Mapping[position].Value, path, LayerCLI)
|
||||
}
|
||||
}
|
||||
result.overlays = append(result.overlays, overlayRecord{change: Change{
|
||||
Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous),
|
||||
}, displaced: displaced})
|
||||
result.applications = append(result.applications, historyApplication{
|
||||
layer: LayerCLI, path: path.clone(), value: override.value,
|
||||
origin: overlayOrigin(LayerCLI, location),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
|
||||
displaced := []displacedDefinition{{path: path.clone(), origin: cloneOrigin(origins[path.String()])}}
|
||||
if existing.Kind == document.Mapping && incoming.Kind == document.Mapping {
|
||||
for _, entry := range incoming.Mapping {
|
||||
position := documentEntryIndex(existing.Mapping, entry.Key)
|
||||
if position < 0 {
|
||||
continue
|
||||
}
|
||||
childPath := path.Key(entry.Key)
|
||||
childExisting := existing.Mapping[position].Value
|
||||
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
|
||||
displaced = append(displaced, affectedOverrideOrigins(childExisting, entry.Value, origins, childPath)...)
|
||||
} else {
|
||||
displaced = append(displaced, displacedOrigins(childExisting, origins, childPath)...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
displaced = displacedOrigins(existing, origins, path)
|
||||
}
|
||||
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
|
||||
return strings.Compare(left.path.String(), right.path.String())
|
||||
})
|
||||
return displaced
|
||||
}
|
||||
@@ -30,7 +30,7 @@ type overrideLifecycleConfig struct {
|
||||
|
||||
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
|
||||
writeImportFile(t, directory, "base.yml", `settings:
|
||||
target: base
|
||||
count: 4
|
||||
@@ -163,7 +163,7 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
|
||||
Nested map[string]any `yaml:"nested"`
|
||||
Unknown string `yaml:"unknown"`
|
||||
}
|
||||
base := Bytes("override-errors.yml", []byte("version: v1\nscalar: value\nitems: [one]\nnested: {}\n"))
|
||||
base := Bytes("override-errors.yml", []byte("version: 4\nscalar: value\nitems: [one]\nnested: {}\n"))
|
||||
version := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
|
||||
cycle := map[string]any{}
|
||||
cycle["self"] = cycle
|
||||
@@ -187,8 +187,8 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
|
||||
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
|
||||
{name: "depth", overrides: []Override{{Path: Keys("nested"), Value: deep, Flag: "--nested"}}, code: CodeOverrideLimitExceeded},
|
||||
{name: "record bytes", overrides: []Override{{Path: Keys("scalar"), Value: strings.Repeat("x", maxSourceBytes+1), Flag: "--scalar"}}, code: CodeOverrideLimitExceeded},
|
||||
{name: "directive path", overrides: []Override{{Path: Keys("_configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
|
||||
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"_configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
|
||||
{name: "directive path", overrides: []Override{{Path: Keys("configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
|
||||
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
|
||||
{name: "version", overrides: []Override{{Path: Keys("version"), Value: "v2", Flag: "--version"}}, code: CodeOverrideVersion},
|
||||
{name: "version ancestor", overrides: []Override{{Path: Keys("version"), Value: map[string]any{"child": "v2"}, Flag: "--version"}}, code: CodeOverrideVersion},
|
||||
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},
|
||||
@@ -213,7 +213,7 @@ func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
|
||||
type config struct {
|
||||
Schema schemaConfig `yaml:"schema"`
|
||||
}
|
||||
_, err := Load(Bytes("version.yml", []byte("schema: {version: v1}\n")), Options[config]{
|
||||
_, err := Load(Bytes("version.yml", []byte("schema: {version: 4}\n")), Options[config]{
|
||||
Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
||||
Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}},
|
||||
})
|
||||
|
||||
150
override_value.go
Normal file
150
override_value.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type overrideValueBuilder struct {
|
||||
sourceID string
|
||||
active map[valueVisit]bool
|
||||
bytes int64
|
||||
nodes int
|
||||
}
|
||||
|
||||
func (b *overrideValueBuilder) build(value any, path Path, depth int) (*document.Node, *Diagnostic) {
|
||||
if depth > maxDocumentDepth {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the depth limit")
|
||||
}
|
||||
b.nodes++
|
||||
if b.nodes > maxDocumentNodes {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||
}
|
||||
location := document.Location{SourceID: b.sourceID}
|
||||
switch value := value.(type) {
|
||||
case nil:
|
||||
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||
case string:
|
||||
if !b.addBytes(len(value)) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the record string-byte limit")
|
||||
}
|
||||
return &document.Node{Kind: document.String, Text: value, Location: location}, nil
|
||||
case bool:
|
||||
return &document.Node{Kind: document.Boolean, Text: strconv.FormatBool(value), Location: location}, nil
|
||||
case int:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int8:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int16:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int32:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int64:
|
||||
return overrideNumber(strconv.FormatInt(value, 10), location), nil
|
||||
case uint:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint8:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint16:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint32:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint64:
|
||||
return overrideNumber(strconv.FormatUint(value, 10), location), nil
|
||||
case float32:
|
||||
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
|
||||
}
|
||||
return overrideNumber(strconv.FormatFloat(float64(value), 'g', -1, 32), location), nil
|
||||
case float64:
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
|
||||
}
|
||||
return overrideNumber(strconv.FormatFloat(value, 'g', -1, 64), location), nil
|
||||
case []any:
|
||||
if value == nil {
|
||||
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||
}
|
||||
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
defer leave()
|
||||
if len(value) > maxDocumentNodes-b.nodes {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||
}
|
||||
node := &document.Node{Kind: document.Sequence, Location: location, Sequence: make([]*document.Node, len(value))}
|
||||
for index, child := range value {
|
||||
built, diagnostic := b.build(child, path.Index(index), depth+1)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
node.Sequence[index] = built
|
||||
}
|
||||
return node, nil
|
||||
case map[string]any:
|
||||
if value == nil {
|
||||
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||
}
|
||||
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
defer leave()
|
||||
if len(value) > (maxDocumentNodes-b.nodes)/2 {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||
}
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
node := &document.Node{Kind: document.Mapping, Location: location, Mapping: make([]document.Entry, 0, len(keys))}
|
||||
for _, key := range keys {
|
||||
b.nodes++ // mapping key
|
||||
if !b.addBytes(len(key)) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path.Key(key), "configuration override exceeds the record string-byte limit")
|
||||
}
|
||||
built, diagnostic := b.build(value[key], path.Key(key), depth+1)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
node.Mapping = append(node.Mapping, document.Entry{Key: key, KeyLocation: location, ValueLocation: location, Value: built})
|
||||
}
|
||||
return node, nil
|
||||
default:
|
||||
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override value has an unsupported type")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *overrideValueBuilder) addBytes(count int) bool {
|
||||
if count < 0 || int64(count) > maxSourceBytes-b.bytes {
|
||||
return false
|
||||
}
|
||||
b.bytes += int64(count)
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *overrideValueBuilder) enter(value reflect.Value, path Path) (func(), *Diagnostic) {
|
||||
visit, ok := visitFor(value)
|
||||
if !ok {
|
||||
return func() {}, nil
|
||||
}
|
||||
if b.active[visit] {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override values must not contain cycles")
|
||||
}
|
||||
b.active[visit] = true
|
||||
return func() { delete(b.active, visit) }, nil
|
||||
}
|
||||
|
||||
func overrideValueDiagnostic(code Code, path Path, message string) *Diagnostic {
|
||||
return &Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
|
||||
}
|
||||
|
||||
func overrideNumber(text string, location document.Location) *document.Node {
|
||||
return &document.Node{Kind: document.Number, Text: text, Location: location}
|
||||
}
|
||||
37
parse.go
37
parse.go
@@ -33,6 +33,21 @@ type parsedSource struct {
|
||||
bytes int64
|
||||
}
|
||||
|
||||
// parserDiagnostics translates adapter issues into detached, ordered public records.
|
||||
func parserDiagnostics(sourceID string, issues []yamladapter.Issue) []Diagnostic {
|
||||
diagnostics := make([]Diagnostic, len(issues))
|
||||
for index, issue := range issues {
|
||||
diagnostics[index] = Diagnostic{
|
||||
Code: Code(issue.Code), Severity: SeverityError, Message: issue.Message,
|
||||
Primary: &Location{SourceID: sourceID, Line: issue.Line, Column: issue.Column},
|
||||
}
|
||||
for _, related := range issue.Related {
|
||||
diagnostics[index].Related = append(diagnostics[index].Related, publicLocation(related))
|
||||
}
|
||||
}
|
||||
return canonicalDiagnostics(diagnostics)
|
||||
}
|
||||
|
||||
func parseSource(source Source, sourceID string, limits parseLimits) (parsedSource, []Diagnostic) {
|
||||
read, diagnostics := readSource(source, sourceID, limits.source)
|
||||
if len(diagnostics) != 0 || read.info == nil {
|
||||
@@ -41,27 +56,7 @@ func parseSource(source Source, sourceID string, limits parseLimits) (parsedSour
|
||||
|
||||
root, issues := yamladapter.Parse(bytes.NewReader(read.data), sourceID, limits.document)
|
||||
if len(issues) != 0 {
|
||||
diagnostics := make([]Diagnostic, len(issues))
|
||||
for i, issue := range issues {
|
||||
diagnostics[i] = Diagnostic{
|
||||
Code: Code(issue.Code),
|
||||
Severity: SeverityError,
|
||||
Message: issue.Message,
|
||||
Primary: &Location{
|
||||
SourceID: sourceID,
|
||||
Line: issue.Line,
|
||||
Column: issue.Column,
|
||||
},
|
||||
}
|
||||
for _, related := range issue.Related {
|
||||
diagnostics[i].Related = append(diagnostics[i].Related, Location{
|
||||
SourceID: related.SourceID,
|
||||
Line: related.Line,
|
||||
Column: related.Column,
|
||||
})
|
||||
}
|
||||
}
|
||||
return parsedSource{source: read.info, bytes: int64(len(read.data))}, canonicalDiagnostics(diagnostics)
|
||||
return parsedSource{source: read.info, bytes: int64(len(read.data))}, parserDiagnostics(sourceID, issues)
|
||||
}
|
||||
return parsedSource{document: root, source: read.info, bytes: int64(len(read.data))}, nil
|
||||
}
|
||||
|
||||
74
profile.go
74
profile.go
@@ -3,14 +3,12 @@ package configkit
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||
)
|
||||
|
||||
@@ -68,9 +66,14 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS
|
||||
value.Primary = nil
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{value}
|
||||
}
|
||||
rootClosed := false
|
||||
defer func() {
|
||||
if !rootClosed {
|
||||
_ = root.Close()
|
||||
}
|
||||
}()
|
||||
relative, err := filepath.Rel(rootPath, canonical)
|
||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
_ = root.Close()
|
||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile could not be addressed inside the configuration root", nil)}
|
||||
}
|
||||
|
||||
@@ -80,32 +83,29 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS
|
||||
sources = append(cloneSourceInfos(sources), profileSource)
|
||||
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
|
||||
if diagnostic != nil {
|
||||
_ = root.Close()
|
||||
return nil, sources, []Diagnostic{*diagnostic}
|
||||
}
|
||||
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
|
||||
if len(issues) != 0 {
|
||||
_ = root.Close()
|
||||
return nil, sources, importParseDiagnostics(sourceID, issues)
|
||||
return nil, sources, parserDiagnostics(sourceID, issues)
|
||||
}
|
||||
nodeCount := countInputNodes(profileRoot)
|
||||
if base.inputNodes+nodeCount > limits.nodes {
|
||||
_ = root.Close()
|
||||
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
|
||||
}
|
||||
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
|
||||
_ = root.Close()
|
||||
return nil, sources, diagnostics
|
||||
}
|
||||
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
|
||||
location := publicLocation(versionNode.Location)
|
||||
_ = root.Close()
|
||||
return nil, sources, []Diagnostic{{
|
||||
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location,
|
||||
}}
|
||||
}
|
||||
if err := root.Close(); err != nil {
|
||||
closeErr := root.Close()
|
||||
rootClosed = true
|
||||
if closeErr != nil {
|
||||
return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected profile", nil)}
|
||||
}
|
||||
|
||||
@@ -122,58 +122,10 @@ func readProfileFile(root *os.Root, relative, sourceID string, remaining int64)
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
file, err := root.Open(relative)
|
||||
if err != nil {
|
||||
value := classifySourceError(err, sourceID)
|
||||
return nil, &value
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
value := classifySourceError(err, sourceID)
|
||||
return nil, &value
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
||||
return nil, &value
|
||||
}
|
||||
if info.Size() > limit {
|
||||
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
||||
return nil, &value
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
||||
if err != nil {
|
||||
value := classifySourceError(err, sourceID)
|
||||
return nil, &value
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
||||
data, failure := readRegularFile(relative, root.Stat, root.Open, limit)
|
||||
if failure != nil {
|
||||
value := sourceReadDiagnostic(failure, sourceID, CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit")
|
||||
return nil, &value
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
||||
if node.Kind == document.Mapping {
|
||||
for _, entry := range node.Mapping {
|
||||
childPath := current.Key(entry.Key)
|
||||
if entry.Key == "_configkit" {
|
||||
location := publicLocation(entry.KeyLocation)
|
||||
return []Diagnostic{{
|
||||
Code: CodeProfileDirective, Severity: SeverityError, Path: childPath,
|
||||
Message: "profiles cannot contain configuration directives", Primary: &location,
|
||||
}}
|
||||
}
|
||||
if diagnostics := profileDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for index, child := range node.Sequence {
|
||||
if diagnostics := profileDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type profileTestConfig struct {
|
||||
|
||||
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports: [{path: base.yml}]
|
||||
settings:
|
||||
kept: main
|
||||
@@ -123,7 +123,7 @@ func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
|
||||
|
||||
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
||||
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
|
||||
if !version.Present || version.Text != "v1" {
|
||||
if !version.Present || version.Text != "4" {
|
||||
return errors.New("unexpected version")
|
||||
}
|
||||
return nil
|
||||
@@ -133,14 +133,14 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
||||
profile string
|
||||
code Code
|
||||
}{
|
||||
{name: "root directive", profile: "_configkit: {}\n", code: CodeProfileDirective},
|
||||
{name: "nested directive", profile: "settings: {_configkit: {}}\n", code: CodeProfileDirective},
|
||||
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
|
||||
{name: "root directive", profile: "configkit: {}\n", code: CodeProfileDirective},
|
||||
{name: "nested directive", profile: "settings: {configkit: {}}\n", code: CodeProfileDirective},
|
||||
{name: "equal integer version", profile: "schema: {version: 4}\n", code: CodeProfileVersion},
|
||||
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
||||
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
|
||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
|
||||
})
|
||||
@@ -148,7 +148,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
||||
|
||||
t.Run("mapping sibling is allowed", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
||||
writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n")
|
||||
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
|
||||
if err != nil || result.Config.Schema["other"] != "allowed" {
|
||||
@@ -158,7 +158,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
||||
|
||||
t.Run("profile aliases imported base", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: profiles/testing.yml}]}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/testing.yml}]}\n")
|
||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
|
||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
|
||||
})
|
||||
|
||||
379
projection.go
379
projection.go
@@ -1,6 +1,7 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding"
|
||||
"math"
|
||||
"reflect"
|
||||
@@ -12,34 +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 {
|
||||
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"}
|
||||
@@ -48,14 +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) {
|
||||
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
|
||||
@@ -117,6 +82,15 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
||||
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() {
|
||||
@@ -134,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())
|
||||
@@ -243,169 +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 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
|
||||
@@ -416,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() {
|
||||
@@ -493,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ type layeredConfig struct {
|
||||
|
||||
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports: [{path: imported.yml}]
|
||||
shared: {equal: same}
|
||||
values: [1, 2]
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,14 @@ const (
|
||||
LayerResolution Layer = "resolution"
|
||||
)
|
||||
|
||||
// ChangeKind identifies an application resolution operation. The zero value is
|
||||
// ChangeKind identifies a recorded operation. The zero value is
|
||||
// used for input and default origins.
|
||||
type ChangeKind string
|
||||
|
||||
const (
|
||||
// ChangeDecoding records the built-in transition to canonical typed values.
|
||||
// It is inspection metadata, not an operation accepted by Resolution.Record.
|
||||
ChangeDecoding ChangeKind = "decoding"
|
||||
ChangeDerivedDefault ChangeKind = "derived_default"
|
||||
ChangeNormalization ChangeKind = "normalization"
|
||||
)
|
||||
|
||||
108
release_check_test.go
Normal file
108
release_check_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckScriptsHandleFilenamesAndStopOnFailure(t *testing.T) {
|
||||
for _, scriptName := range []string{"release-check.sh", "check.sh"} {
|
||||
t.Run(scriptName, func(t *testing.T) {
|
||||
script, err := os.ReadFile("scripts/" + scriptName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"success", "failed-go-check", "untracked-whitespace"} {
|
||||
fail := name != "success"
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bin := t.TempDir()
|
||||
log := filepath.Join(bin, "gofmt.args")
|
||||
calls := filepath.Join(bin, "go.calls")
|
||||
for _, directory := range []string{"scripts", "nested"} {
|
||||
if err := os.Mkdir(filepath.Join(root, directory), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write := func(path string, data []byte, mode os.FileMode) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, data, mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(filepath.Join(root, "scripts", scriptName), script, 0700)
|
||||
write(filepath.Join(root, "go.mod"), []byte("module example.invalid/check\n"), 0600)
|
||||
write(filepath.Join(root, "go.sum"), nil, 0600)
|
||||
write(filepath.Join(root, "obsolete.go"), []byte("package obsolete\n"), 0600)
|
||||
files := []string{"nested/a space.go", "nested/a\nnewline.go"}
|
||||
for _, file := range files {
|
||||
write(filepath.Join(root, file), []byte("package example\n"), 0600)
|
||||
}
|
||||
if name == "untracked-whitespace" {
|
||||
write(filepath.Join(root, "notes.md"), []byte("trailing space \n"), 0600)
|
||||
}
|
||||
// Isolate expensive toolchain commands: this test owns shell orchestration,
|
||||
// not Go's compiler or the production tests exercised by the real checker.
|
||||
write(filepath.Join(bin, "go"), []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CONFIGKIT_TEST_GO_LOG\"\nif [ \"${CONFIGKIT_TEST_FAIL-}\" = yes ] && [ \"${1-}\" = test ]; then exit 37; fi\n"), 0700)
|
||||
write(filepath.Join(bin, "gofmt"), []byte("#!/bin/sh\nprintf '%s\\000' \"$@\" > \"$CONFIGKIT_TEST_FMT_LOG\"\n"), 0700)
|
||||
init := exec.Command("git", "init", "-q")
|
||||
init.Dir = root
|
||||
if out, err := init.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git init: %v %s", err, out)
|
||||
}
|
||||
// HEAD is required by the whitespace gate. Commit only the module baseline.
|
||||
for _, args := range [][]string{{"add", "go.mod", "go.sum", "obsolete.go"}, {"-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "-qm", "Test fixture"}} {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = root
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git fixture: %v %s", err, out)
|
||||
}
|
||||
}
|
||||
if err := os.Remove(filepath.Join(root, "obsolete.go")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName))
|
||||
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline", "CONFIGKIT_NEWER_TOOLCHAIN=test-newer", "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls)
|
||||
if name == "failed-go-check" {
|
||||
cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=yes")
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if fail {
|
||||
if err == nil {
|
||||
t.Fatal("checker ignored failed Go test")
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("checker: %v %s", err, out)
|
||||
}
|
||||
arguments, err := os.ReadFile(log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := bytes.Split(arguments, []byte{0})
|
||||
for _, file := range files {
|
||||
found := false
|
||||
for _, arg := range parts {
|
||||
if string(arg) == file {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("filename was split: %q", file)
|
||||
}
|
||||
}
|
||||
invoked, err := os.ReadFile(calls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fail && strings.Contains(string(invoked), "vet ./...") {
|
||||
t.Fatal("checker continued after failed test")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -175,3 +175,76 @@ func TestSecretValuesStayOutOfLoadFailuresAndWarnings(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDynamicConcreteTagsRemainSecretThroughHistory(t *testing.T) {
|
||||
type sensitive struct {
|
||||
Token string `yaml:"token" configkit:"secret"`
|
||||
}
|
||||
type cfg struct {
|
||||
Payload any `yaml:"payload"`
|
||||
Derived string `yaml:"derived"`
|
||||
}
|
||||
for _, change := range []string{"retain", "replace", "delete", "introduce"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
const canary = "DYNAMIC_TAG_CANARY"
|
||||
definition := Definition[cfg]{Defaults: func() cfg { return cfg{Payload: map[string]any{"nested": []any{sensitive{Token: canary}}}} }}
|
||||
path := Keys("payload", "nested").Index(0).Key("token")
|
||||
if change == "introduce" {
|
||||
definition.Defaults = nil
|
||||
}
|
||||
definition.Resolve = func(c *cfg, r *Resolution) error {
|
||||
if change == "introduce" {
|
||||
Normalize(r, Keys("payload"), &c.Payload, any(map[string]any{"nested": []any{sensitive{Token: canary}}}), "create")
|
||||
}
|
||||
if change == "replace" {
|
||||
Normalize(r, Keys("payload"), &c.Payload, any("public"), "replace")
|
||||
}
|
||||
if change == "delete" {
|
||||
Normalize(r, Keys("payload"), &c.Payload, any(nil), "remove")
|
||||
}
|
||||
Normalize(r, Keys("derived"), &c.Derived, canary, "derive", path)
|
||||
return nil
|
||||
}
|
||||
result, err := Load(NoSource(), Options[cfg]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := result.WriteEffective(&output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, query := range []Path{Keys(), path, Keys("derived")} {
|
||||
explanation, _ := result.Explain(query)
|
||||
output.WriteString(fmt.Sprintf("%#v", explanation))
|
||||
}
|
||||
if strings.Contains(output.String(), canary) {
|
||||
t.Fatal("dynamic secret escaped inspection")
|
||||
}
|
||||
if !result.secrets[path.String()] || !result.secrets["/derived"] {
|
||||
t.Fatal("dynamic tag or dependency classification missing")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorCannotChangeDynamicSensitivityWithoutChangingText(t *testing.T) {
|
||||
type sensitive struct {
|
||||
Token string `yaml:"token" configkit:"secret"`
|
||||
}
|
||||
type cfg struct {
|
||||
Payload any `yaml:"payload"`
|
||||
}
|
||||
_, err := Load(Bytes("base", []byte("payload: {token: disposable}\n")), Options[cfg]{Definition: Definition[cfg]{Validate: func(c *cfg, v *Validation) {
|
||||
c.Payload = sensitive{Token: c.Payload.(map[string]any)["token"].(string)}
|
||||
}}})
|
||||
var failure *LoadError
|
||||
if !errors.As(err, &failure) {
|
||||
t.Fatal("validator changed inspection sensitivity without a mutation error")
|
||||
}
|
||||
for _, d := range failure.Diagnostics() {
|
||||
if d.Code == CodeValidateMutation {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("missing validation mutation diagnostic")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -249,16 +249,15 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
||||
}
|
||||
}
|
||||
for _, record := range ordered {
|
||||
beforeNode, beforePresent := documentAtPath(decoded.final, record.path)
|
||||
_, beforePresent := documentAtPath(decoded.final, record.path)
|
||||
afterNode, afterPresent := documentAtPath(after, record.path)
|
||||
if !beforePresent && !afterPresent {
|
||||
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
|
||||
}
|
||||
if hasErrors(state.diagnostics) {
|
||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||
@@ -343,17 +342,3 @@ func clonePaths(paths []Path) []Path {
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
||||
if snapshots == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]resolutionSnapshot, len(snapshots))
|
||||
for index, snapshot := range snapshots {
|
||||
cloned[index] = resolutionSnapshot{
|
||||
change: cloneChange(snapshot.change),
|
||||
before: document.Clone(snapshot.before), after: document.Clone(snapshot.after),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
10
schema.go
10
schema.go
@@ -212,9 +212,17 @@ func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current refle
|
||||
return &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "inline field cannot declare a YAML name"}
|
||||
}
|
||||
inlineType := field.Type
|
||||
seenPointers := make(map[reflect.Type]bool)
|
||||
for inlineType.Kind() == reflect.Pointer {
|
||||
if seenPointers[inlineType] {
|
||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline pointer chain must terminate in a struct"}
|
||||
}
|
||||
seenPointers[inlineType] = true
|
||||
inlineType = inlineType.Elem()
|
||||
}
|
||||
if inlineType.Kind() == reflect.Map && field.Type.Kind() == reflect.Pointer {
|
||||
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline maps cannot be pointers"}
|
||||
}
|
||||
switch inlineType.Kind() {
|
||||
case reflect.Struct:
|
||||
if b.building[inlineType] {
|
||||
@@ -426,5 +434,5 @@ func schemaIndex(segment pathSegment) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
index, err := strconv.Atoi(segment.key)
|
||||
return index, err == nil && index >= 0
|
||||
return index, err == nil && index >= 0 && strconv.Itoa(index) == segment.key
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -302,3 +303,40 @@ func mustPath(t *testing.T, pointer string) Path {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestInlineMapPointersFailBeforeDefaults(t *testing.T) {
|
||||
type cfg struct {
|
||||
Extra *map[string]int `yaml:",inline"`
|
||||
}
|
||||
for _, nonNil := range []bool{false, true} {
|
||||
called := false
|
||||
_, err := Load(NoSource(), Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg {
|
||||
called = true
|
||||
if nonNil {
|
||||
m := map[string]int{"value": 1}
|
||||
return cfg{Extra: &m}
|
||||
}
|
||||
return cfg{}
|
||||
}}})
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) || called {
|
||||
t.Fatalf("invalid inline schema reached defaults: called=%v error=%v", called, err)
|
||||
}
|
||||
if len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != CodeSchemaInline {
|
||||
t.Fatal("missing inline schema diagnostic")
|
||||
}
|
||||
}
|
||||
type pointer *pointer
|
||||
type recursive struct {
|
||||
Extra pointer `yaml:",inline"`
|
||||
}
|
||||
if _, err := Load[recursive](NoSource()); err == nil {
|
||||
t.Fatal("recursive inline pointer was accepted")
|
||||
}
|
||||
type double struct {
|
||||
Extra **map[string]int `yaml:",inline"`
|
||||
}
|
||||
if _, err := Load[double](NoSource()); err == nil {
|
||||
t.Fatal("double inline map pointer was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
41
scripts/check.sh
Executable file
41
scripts/check.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repository_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
cd "$repository_dir"
|
||||
|
||||
# Use the installed toolchain unless the caller explicitly selects another.
|
||||
# Toolchains and dependencies must already be available locally.
|
||||
export GOTOOLCHAIN=${GOTOOLCHAIN:-local}
|
||||
export GOPROXY=off
|
||||
go version
|
||||
|
||||
go_files=()
|
||||
while IFS= read -r -d '' go_file; do
|
||||
if [[ -f "$go_file" ]]; then
|
||||
go_files+=("$go_file")
|
||||
fi
|
||||
done < <(git ls-files --cached --others --exclude-standard -z -- '*.go')
|
||||
if ((${#go_files[@]})); then
|
||||
unformatted=$(gofmt -l -- "${go_files[@]}")
|
||||
if [[ -n "$unformatted" ]]; then
|
||||
printf 'gofmt required for:\n%s\n' "$unformatted" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
git diff --check HEAD --
|
||||
while IFS= read -r -d '' candidate_file; do
|
||||
# --no-index returns 1 for differing content even when whitespace is clean;
|
||||
# whitespace errors return 2. Preserve other failures as well.
|
||||
git diff --no-index --check -- /dev/null "$candidate_file" || {
|
||||
check_status=$?
|
||||
if ((check_status != 1)); then
|
||||
exit "$check_status"
|
||||
fi
|
||||
}
|
||||
done < <(git ls-files --others --exclude-standard -z)
|
||||
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
@@ -25,8 +25,13 @@ bash -n scripts/release-check.sh
|
||||
|
||||
temporary_dir=$(mktemp -d)
|
||||
trap 'rm -rf -- "$temporary_dir"' EXIT
|
||||
git ls-files --cached --others --exclude-standard -z |
|
||||
tar --null --files-from=- --create --file=- |
|
||||
candidate_manifest=$temporary_dir/candidate-files
|
||||
while IFS= read -r -d '' candidate_file; do
|
||||
if [[ -e "$candidate_file" || -L "$candidate_file" ]]; then
|
||||
printf '%s\0' "$candidate_file" >>"$candidate_manifest"
|
||||
fi
|
||||
done < <(git ls-files --cached --others --exclude-standard -z)
|
||||
tar --null --files-from="$candidate_manifest" --create --file=- |
|
||||
tar --extract --file=- --directory="$temporary_dir"
|
||||
(
|
||||
cd "$temporary_dir"
|
||||
@@ -35,7 +40,12 @@ git ls-files --cached --others --exclude-standard -z |
|
||||
diff -u go.mod "$temporary_dir/go.mod"
|
||||
diff -u go.sum "$temporary_dir/go.sum"
|
||||
|
||||
mapfile -d '' go_files < <(git ls-files --cached --others --exclude-standard -z -- '*.go')
|
||||
go_files=()
|
||||
while IFS= read -r -d '' go_file; do
|
||||
if [[ -f "$go_file" ]]; then
|
||||
go_files+=("$go_file")
|
||||
fi
|
||||
done < <(git ls-files --cached --others --exclude-standard -z -- '*.go')
|
||||
if ((${#go_files[@]})); then
|
||||
unformatted=$(gofmt -l -- "${go_files[@]}")
|
||||
if [[ -n "$unformatted" ]]; then
|
||||
@@ -46,6 +56,9 @@ fi
|
||||
|
||||
git diff --check HEAD --
|
||||
while IFS= read -r -d '' candidate_file; do
|
||||
if [[ ! -f "$candidate_file" ]]; then
|
||||
continue
|
||||
fi
|
||||
case "$candidate_file" in
|
||||
*.go|*.md|*.sh|go.mod|go.sum|LICENSE|.gitignore|AGENTS.md)
|
||||
if LC_ALL=C grep -nE '[[:blank:]]+$' -- "$candidate_file"; then
|
||||
|
||||
24
secret.go
24
secret.go
@@ -7,7 +7,7 @@ type secretDependency struct {
|
||||
dependency Path
|
||||
}
|
||||
|
||||
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication) map[string]bool {
|
||||
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool {
|
||||
paths := make(map[string]Path)
|
||||
finalPaths := make(map[string]*document.Node)
|
||||
indexDocument(final, Path{}, finalPaths)
|
||||
@@ -36,6 +36,16 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
|
||||
}
|
||||
|
||||
secret := make(map[string]bool)
|
||||
for _, tree := range append(projections, final) {
|
||||
projected := make(map[string]*document.Node)
|
||||
indexDocument(tree, Path{}, projected)
|
||||
for pointer, node := range projected {
|
||||
addSecretPath(paths, pointer)
|
||||
if node.Sensitive {
|
||||
secret[pointer] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for pointer, path := range paths {
|
||||
if root, ok := schemaSecretRoot(schema, path); ok {
|
||||
secret[root.String()] = true
|
||||
@@ -129,3 +139,15 @@ func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
|
||||
}
|
||||
return Path{}, false
|
||||
}
|
||||
|
||||
// Dynamic type replacement can change classification even when projected text
|
||||
// is equal. Validators must preserve this inspection metadata as well as values.
|
||||
func projectedSensitivity(root *document.Node) map[string]Path {
|
||||
result := make(map[string]Path)
|
||||
walkDocument(root, Path{}, func(path Path, node *document.Node) {
|
||||
if node.Sensitive {
|
||||
result[path.String()] = path.clone()
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -211,3 +212,14 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneSecretReferences(values map[string]string) map[string]string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]string, len(values))
|
||||
for path, name := range values {
|
||||
cloned[path] = name
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
63
snapshot.go
Normal file
63
snapshot.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||
)
|
||||
|
||||
// Snapshot projection is the shared redaction boundary for effective YAML
|
||||
// and explanation values. It borrows frozen trees and returns a detached tree.
|
||||
func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot {
|
||||
node, present := documentAtPath(root, path)
|
||||
if !present {
|
||||
return ValueSnapshot{}
|
||||
}
|
||||
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
||||
var output bytes.Buffer
|
||||
if err := yamladapter.Write(&output, safe); err != nil {
|
||||
return ValueSnapshot{Present: true, Redacted: true, YAML: "'[REDACTED]'\n"}
|
||||
}
|
||||
return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()}
|
||||
}
|
||||
|
||||
func safeSnapshotDocument(node *document.Node, secrets map[string]bool, path Path) (*document.Node, bool) {
|
||||
if node == nil {
|
||||
return nil, false
|
||||
}
|
||||
if pathIsSecret(secrets, path) {
|
||||
return &document.Node{Kind: document.String, Text: "[REDACTED]"}, true
|
||||
}
|
||||
result := *node
|
||||
redacted := false
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
result.Mapping = make([]document.Entry, len(node.Mapping))
|
||||
copy(result.Mapping, node.Mapping)
|
||||
sortDocumentMapping(result.Mapping)
|
||||
for index := range result.Mapping {
|
||||
entry := &result.Mapping[index]
|
||||
entry.Value, redacted = safeSnapshotChild(entry.Value, secrets, path.Key(entry.Key), redacted)
|
||||
}
|
||||
case document.Sequence:
|
||||
result.Sequence = make([]*document.Node, len(node.Sequence))
|
||||
for index, child := range node.Sequence {
|
||||
result.Sequence[index], redacted = safeSnapshotChild(child, secrets, path.Index(index), redacted)
|
||||
}
|
||||
}
|
||||
return &result, redacted
|
||||
}
|
||||
|
||||
func safeSnapshotChild(node *document.Node, secrets map[string]bool, path Path, prior bool) (*document.Node, bool) {
|
||||
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
||||
return safe, prior || redacted
|
||||
}
|
||||
|
||||
func sortDocumentMapping(entries []document.Entry) {
|
||||
slices.SortFunc(entries, func(left, right document.Entry) int {
|
||||
return strings.Compare(left.Key, right.Key)
|
||||
})
|
||||
}
|
||||
41
source.go
41
source.go
@@ -125,10 +125,7 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
||||
}
|
||||
info := &SourceInfo{ID: sourceID, Label: source.label, Layer: LayerBase}
|
||||
|
||||
var (
|
||||
input io.Reader
|
||||
close func() error
|
||||
)
|
||||
var input io.Reader
|
||||
switch source.kind {
|
||||
case sourceFile:
|
||||
absolute, err := filepath.Abs(source.path)
|
||||
@@ -136,31 +133,16 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceRead, sourceID, "source path could not be resolved")}
|
||||
}
|
||||
info.Path = filepath.Clean(absolute)
|
||||
file, err := os.Open(source.path)
|
||||
if err != nil {
|
||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
||||
}
|
||||
close = file.Close
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
_ = file.Close()
|
||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")}
|
||||
}
|
||||
canonical, err := filepath.EvalSymlinks(info.Path)
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
||||
}
|
||||
info.Path = canonical
|
||||
if stat.Size() > limit {
|
||||
_ = file.Close()
|
||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "source exceeds the configured byte limit")}
|
||||
data, failure := readRegularFile(canonical, os.Stat, os.Open, limit)
|
||||
if failure != nil {
|
||||
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
||||
}
|
||||
input = file
|
||||
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
||||
case sourceBytes:
|
||||
input = bytes.NewReader(source.data)
|
||||
case sourceReader:
|
||||
@@ -171,16 +153,9 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
||||
default:
|
||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
|
||||
}
|
||||
if close != nil {
|
||||
defer func() { _ = close() }()
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(input, limit+1))
|
||||
if err != nil {
|
||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "source exceeds the configured byte limit")}
|
||||
data, failure := readBoundedSource(input, limit)
|
||||
if failure != nil {
|
||||
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
||||
}
|
||||
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
||||
}
|
||||
|
||||
71
source_fifo_test.go
Normal file
71
source_fifo_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNonregularSourcesDoNotBlock(t *testing.T) {
|
||||
if mode := os.Getenv("CONFIGKIT_FIFO_TEST_MODE"); mode != "" {
|
||||
root := os.Getenv("CONFIGKIT_FIFO_TEST_ROOT")
|
||||
source := File(filepath.Join(root, "config.yml"))
|
||||
opts := Options[map[string]any]{}
|
||||
switch mode {
|
||||
case "file":
|
||||
source = File(filepath.Join(root, "pipe.yml"))
|
||||
case "import":
|
||||
opts.Imports = true
|
||||
case "profile":
|
||||
opts.Profile = "fifo"
|
||||
}
|
||||
_, err := Load(source, opts)
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("expected safe load error, got %v", err)
|
||||
}
|
||||
for _, d := range loadErr.Diagnostics() {
|
||||
if d.Code == CodeSourceNotRegular {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected nonregular classification, got %v", loadErr.Diagnostics())
|
||||
}
|
||||
for _, mode := range []string{"file", "import", "profile"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
input := "value: 1\n"
|
||||
if mode == "import" {
|
||||
input = "configkit:\n imports:\n - path: pipe.yml\n"
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte(input), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(root, "profiles"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fifo := filepath.Join(root, "pipe.yml")
|
||||
if mode == "profile" {
|
||||
fifo = filepath.Join(root, "profiles", "fifo.yml")
|
||||
}
|
||||
if err := syscall.Mkfifo(fifo, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestNonregularSourcesDoNotBlock$")
|
||||
cmd.Env = append(os.Environ(), "CONFIGKIT_FIFO_TEST_MODE="+mode, "CONFIGKIT_FIFO_TEST_ROOT="+root)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("FIFO load failed or blocked: %v, %s", err, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
75
source_file.go
Normal file
75
source_file.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
)
|
||||
|
||||
type sourceReadFailureKind uint8
|
||||
|
||||
const (
|
||||
sourceReadIO sourceReadFailureKind = iota
|
||||
sourceReadNotRegular
|
||||
sourceReadLimit
|
||||
)
|
||||
|
||||
// sourceReadFailure never escapes the private source boundary. missingTarget is
|
||||
// set only for preflight/open failures; a later missing-file error is not optional.
|
||||
type sourceReadFailure struct {
|
||||
kind sourceReadFailureKind
|
||||
cause error
|
||||
missingTarget bool
|
||||
}
|
||||
|
||||
// readRegularFile borrows stat/open operations so root-bound callers retain their
|
||||
// containment policy. Preflight rejects FIFOs before open can block. The opened
|
||||
// handle is checked again and always closed; read-only close errors are ignored.
|
||||
func readRegularFile(name string, stat func(string) (fs.FileInfo, error), open func(string) (*os.File, error), limit int64) ([]byte, *sourceReadFailure) {
|
||||
info, err := stat(name)
|
||||
if err != nil {
|
||||
return nil, &sourceReadFailure{cause: err, missingTarget: errors.Is(err, fs.ErrNotExist)}
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, &sourceReadFailure{kind: sourceReadNotRegular}
|
||||
}
|
||||
file, err := open(name)
|
||||
if err != nil {
|
||||
return nil, &sourceReadFailure{cause: err, missingTarget: errors.Is(err, fs.ErrNotExist)}
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
info, err = file.Stat()
|
||||
if err != nil {
|
||||
return nil, &sourceReadFailure{cause: err}
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, &sourceReadFailure{kind: sourceReadNotRegular}
|
||||
}
|
||||
if info.Size() > limit {
|
||||
return nil, &sourceReadFailure{kind: sourceReadLimit}
|
||||
}
|
||||
return readBoundedSource(file, limit)
|
||||
}
|
||||
|
||||
func readBoundedSource(input io.Reader, limit int64) ([]byte, *sourceReadFailure) {
|
||||
data, err := io.ReadAll(io.LimitReader(input, limit+1))
|
||||
if err != nil {
|
||||
return nil, &sourceReadFailure{cause: err}
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, &sourceReadFailure{kind: sourceReadLimit}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func sourceReadDiagnostic(failure *sourceReadFailure, sourceID string, limitCode Code, limitMessage string) Diagnostic {
|
||||
switch failure.kind {
|
||||
case sourceReadNotRegular:
|
||||
return sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
||||
case sourceReadLimit:
|
||||
return sourceDiagnostic(limitCode, sourceID, limitMessage)
|
||||
default:
|
||||
return classifySourceError(failure.cause, sourceID)
|
||||
}
|
||||
}
|
||||
48
source_file_test.go
Normal file
48
source_file_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Files disappearing between filesystem operations are not deterministic with
|
||||
// real concurrent deletion. Exercise the private seam that controls optionality.
|
||||
func TestRegularFileMissingFailurePhases(t *testing.T) {
|
||||
name := filepath.Join(t.TempDir(), "input.yml")
|
||||
if err := os.WriteFile(name, []byte("{}"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
stat func(string) (fs.FileInfo, error)
|
||||
open func(string) (*os.File, error)
|
||||
missing bool
|
||||
}{
|
||||
{"preflight", func(string) (fs.FileInfo, error) { return nil, fs.ErrNotExist }, os.Open, true},
|
||||
{"open", os.Stat, func(string) (*os.File, error) { return nil, fs.ErrNotExist }, true},
|
||||
{"post-open stat", os.Stat, func(name string) (*os.File, error) {
|
||||
file, err := os.Open(name)
|
||||
if err == nil {
|
||||
err = file.Close()
|
||||
}
|
||||
return file, err
|
||||
}, false},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
data, failure := readRegularFile(name, test.stat, test.open, 16)
|
||||
if data != nil || failure == nil || failure.kind != sourceReadIO || failure.missingTarget != test.missing {
|
||||
t.Fatalf("data = %v, failure = %#v", data, failure)
|
||||
}
|
||||
})
|
||||
}
|
||||
_, failure := readBoundedSource(missingDuringRead{}, 16)
|
||||
if failure == nil || failure.kind != sourceReadIO || failure.missingTarget {
|
||||
t.Fatalf("late read failure = %#v", failure)
|
||||
}
|
||||
}
|
||||
|
||||
type missingDuringRead struct{}
|
||||
|
||||
func (missingDuringRead) Read([]byte) (int, error) { return 0, fs.ErrNotExist }
|
||||
@@ -52,7 +52,7 @@ func TestFileSourcesReopenAndClassifyFailures(t *testing.T) {
|
||||
}
|
||||
source := File(path)
|
||||
first, diagnostics := readSource(source, "source-1", defaultSourceLimits())
|
||||
if len(diagnostics) != 0 || string(first.data) != "value: first\n" || first.info.Path != path {
|
||||
if len(diagnostics) != 0 || string(first.data) != "value: first\n" || first.info.Path != canonicalTestPath(t, path) || first.info.Label != path {
|
||||
t.Fatalf("first read = %#v, %#v", first, diagnostics)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("value: second\n"), 0o600); err != nil {
|
||||
|
||||
@@ -109,9 +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))
|
||||
}
|
||||
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))
|
||||
}
|
||||
@@ -166,3 +164,85 @@ 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
|
||||
}
|
||||
|
||||
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
||||
if rule == nil {
|
||||
return nil
|
||||
}
|
||||
version := Version{}
|
||||
node, present := documentAtPath(root, rule.Path)
|
||||
if present {
|
||||
version.Present = true
|
||||
switch node.Kind {
|
||||
case document.Null:
|
||||
version.Null = true
|
||||
case document.String:
|
||||
version.Text = node.Text
|
||||
case document.Number:
|
||||
if isIntegerVersion(node) {
|
||||
version.Text = node.Text
|
||||
break
|
||||
}
|
||||
fallthrough
|
||||
default:
|
||||
location, _ := nearestDocumentLocation(root, rule.Path)
|
||||
primary := documentLocationPointer(location)
|
||||
return []Diagnostic{{
|
||||
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "schema version must be a string, integer, or null", Primary: primary,
|
||||
}}
|
||||
}
|
||||
}
|
||||
state := &validationState{
|
||||
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
|
||||
invalidPath: CodeVersionError, document: root,
|
||||
}
|
||||
validation := &Validation{state: state}
|
||||
err := rule.Check(version, validation)
|
||||
state.active = false
|
||||
if err != nil && !hasErrors(state.diagnostics) {
|
||||
location, _ := nearestDocumentLocation(root, rule.Path)
|
||||
state.diagnostics = append(state.diagnostics, Diagnostic{
|
||||
Code: CodeVersionHookFailed, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "schema-version check failed", Primary: documentLocationPointer(location),
|
||||
})
|
||||
}
|
||||
return canonicalDiagnostics(state.diagnostics)
|
||||
}
|
||||
|
||||
func isIntegerVersion(node *document.Node) bool {
|
||||
return node != nil && node.Kind == document.Number && integerPattern.MatchString(node.Text)
|
||||
}
|
||||
|
||||
func pathMatches(configured *Path, current Path) bool {
|
||||
return configured != nil && current.valid() && matchesPathShape(*configured, current)
|
||||
}
|
||||
|
||||
func documentLocationPointer(location document.Location) *Location {
|
||||
if !locationKnown(location) {
|
||||
return nil
|
||||
}
|
||||
return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
90
version_path_test.go
Normal file
90
version_path_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package configkit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestVersionPathsShareCanonicalSequenceSemantics(t *testing.T) {
|
||||
t.Parallel()
|
||||
type config struct {
|
||||
Versions []string `yaml:"versions"`
|
||||
}
|
||||
for _, token := range []string{"0", "+0", "-0", "00", "+1"} {
|
||||
for _, value := range []string{"4", "'4'"} {
|
||||
t.Run(token+"/"+value, func(t *testing.T) {
|
||||
path, err := ParsePath("/versions/" + token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var checked Version
|
||||
result, err := Load[config](Bytes("version.yml", []byte("versions: ["+value+"]\n")), Options[config]{Definition: Definition[config]{
|
||||
Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error { checked = v; return nil }},
|
||||
}})
|
||||
want := Version{}
|
||||
if token == "0" {
|
||||
want = Version{Present: true, Text: "4"}
|
||||
}
|
||||
if checked != want {
|
||||
t.Fatalf("checked = %#v, want %#v", checked, want)
|
||||
}
|
||||
if token != "0" && value == "4" {
|
||||
requireDiagnostic(t, err, CodeDecodeType, "/versions/0")
|
||||
return
|
||||
}
|
||||
if err != nil || result.Config.Versions[0] != "4" {
|
||||
t.Fatalf("result = %#v, err = %v", result, err)
|
||||
}
|
||||
_, found := result.Metadata().Lookup(path)
|
||||
if found != (token == "0") {
|
||||
t.Fatalf("metadata lookup = %v", found)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionPathsPreserveLiteralMappingKeysAndSegmentIntent(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, key := range []string{"0", "+0", "-0", "00", "a/b~"} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
path, err := ParsePath(Keys(key).String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := Load[map[string]string](Bytes("version.yml", []byte("'"+key+"': 4\n")), Options[map[string]string]{Definition: Definition[map[string]string]{
|
||||
Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error {
|
||||
if !v.Present || v.Text != "4" {
|
||||
t.Errorf("checked = %#v", v)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}})
|
||||
if err != nil || result.Config[key] != "4" {
|
||||
t.Fatalf("result = %#v, err = %v", result, err)
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(path); !ok {
|
||||
t.Fatal("literal key missing from metadata")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path Path
|
||||
input string
|
||||
}{
|
||||
{"key cannot address sequence", Keys("versions", "0"), "versions: [4]\n"},
|
||||
{"index cannot address mapping", Keys("versions").Index(0), "versions: {'0': 4}\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
type config struct {
|
||||
Versions any `yaml:"versions"`
|
||||
}
|
||||
_, err := Load[config](Bytes("version.yml", []byte(test.input)), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: test.path, Check: func(v Version, validation *Validation) error {
|
||||
if v.Present {
|
||||
t.Errorf("wrong segment kind found version: %#v", v)
|
||||
}
|
||||
validation.Error(Keys("versions"), "version absent")
|
||||
return nil
|
||||
}}}})
|
||||
requireDiagnostic(t, err, CodeVersionError, "/versions")
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user