Compare commits
25 Commits
f42ecc70c3
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
The [configuration contract](docs/config.md) describes implemented behavior. The
|
||||||
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
|
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
|
||||||
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
|
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
|
||||||
The [initial target record](docs/roadmap/initial.md) links implemented feature owners
|
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the
|
||||||
and preserves deliberately deferred scope. The [implementation plan](docs/roadmap/implementation.md)
|
|
||||||
records completion evidence until release-candidate preparation retires it. See the
|
|
||||||
[development guide](docs/development.md) for repository orientation and the
|
[development guide](docs/development.md) for repository orientation and the
|
||||||
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
||||||
steps.
|
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
|
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 {
|
type compositionClaim struct {
|
||||||
path Path
|
path Path
|
||||||
kind document.Kind
|
kind document.Kind
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ values: [one, two]
|
|||||||
if len(reversedDiagnostics) != 0 {
|
if len(reversedDiagnostics) != 0 {
|
||||||
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
|
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
|
||||||
}
|
}
|
||||||
if deltas := compareDocumentTrees(composed.root, reversed.root); len(deltas) != 0 {
|
if deltas := compareConfigurationValues(composed.root, reversed.root); len(deltas) != 0 {
|
||||||
t.Fatalf("input order changed effective values: %#v", deltas)
|
t.Fatalf("input order changed effective values: %#v", deltas)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func FuzzCompositionAndOverlay(f *testing.F) {
|
|||||||
if len(reversedDiagnostics) != 0 {
|
if len(reversedDiagnostics) != 0 {
|
||||||
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
|
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
|
||||||
}
|
}
|
||||||
if differences := compareDocumentTrees(composed.root, reversed.root); len(differences) != 0 {
|
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
|
||||||
t.Fatalf("composition order changed effective values: %#v", differences)
|
t.Fatalf("composition order changed effective values: %#v", differences)
|
||||||
}
|
}
|
||||||
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
||||||
|
|||||||
@@ -237,3 +237,12 @@ func escapeControls(value string) string {
|
|||||||
}
|
}
|
||||||
return b.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,
|
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
|
||||||
runs exactly once after successful source parsing, schema construction, optional
|
runs exactly once after successful source parsing, schema construction, optional
|
||||||
schema-version checking, and selected-profile loading. Its result is detached before
|
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:
|
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
|
filesystem context. It also accepts explicit CLI override records, static secret
|
||||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
|
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
|
||||||
and filesystem context as invalid combinations but accepts overrides. Input
|
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.
|
disabled.
|
||||||
|
|
||||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||||
@@ -84,7 +87,10 @@ successful result. Callback panics remain programming failures.
|
|||||||
|
|
||||||
### Layered metadata
|
### 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.
|
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
|
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
|
available source coordinates are retained. `History` records the Go-zero or application
|
||||||
@@ -93,6 +99,13 @@ equal-valued input. Profile replacement removes displaced descendants from effec
|
|||||||
metadata while retaining their base definition and the parent replacement operation
|
metadata while retaining their base definition and the parent replacement operation
|
||||||
in history. All metadata queries and returned nested records are detached.
|
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
|
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
|
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;
|
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
||||||
@@ -113,7 +126,9 @@ selected profile, then synthetic CLI records in supplied order.
|
|||||||
### Secret classification and effective rendering
|
### Secret classification and effective rendering
|
||||||
|
|
||||||
Secret classification combines `configkit:"secret"` field tags with the optional
|
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
|
container makes all descendants sensitive. ConfigKit evaluates final and historical
|
||||||
paths, so replacing or deleting a definition does not erase its classification. The
|
paths, so replacing or deleting a definition does not erase its classification. The
|
||||||
predicate can add sensitivity but cannot declassify a tagged path.
|
predicate can add sensitivity but cannot declassify a tagged path.
|
||||||
@@ -157,11 +172,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
|
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
|
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||||
traversal order, the selected profile, supplied CLI records, and audited net
|
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,
|
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.
|
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
|
queries reconstruct each source or override contribution, while sequences remain
|
||||||
atomic: a descendant history identifies its presence before and after the containing
|
atomic: a descendant history identifies its presence before and after the containing
|
||||||
sequence replacement without implying element-wise merging. Removed paths remain
|
sequence replacement without implying element-wise merging. Removed paths remain
|
||||||
@@ -226,7 +250,9 @@ public-name representation, JSON marshals only the name, and projection, metadat
|
|||||||
histories, diagnostics, effective output,
|
histories, diagnostics, effective output,
|
||||||
and resolution snapshots never retain the credential. Validation remains read-only:
|
and resolution snapshots never retain the credential. Validation remains read-only:
|
||||||
in addition to ordinary projected-value comparison, ConfigKit checks the private
|
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.
|
validation messages value-free and must not include `Value()` in diagnostics.
|
||||||
|
|
||||||
For example, a typed pipeline field is declared as:
|
For example, a typed pipeline field is declared as:
|
||||||
@@ -400,7 +426,7 @@ origin data; history retains the displaced definitions and identifies the parent
|
|||||||
replacement. Surviving profile paths have profile origins, while recursively shared
|
replacement. Surviving profile paths have profile origins, while recursively shared
|
||||||
mapping containers retain their contributing locations.
|
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
|
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
|
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.
|
base parse, composition, or version failure because those checks run first.
|
||||||
@@ -433,7 +459,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
|
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,
|
may create missing mapping ancestors but cannot traverse an existing scalar, null,
|
||||||
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
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
|
configured schema-version path. Unknown fields and effective type mismatches remain
|
||||||
ordinary strict-decoder errors.
|
ordinary strict-decoder errors.
|
||||||
|
|
||||||
@@ -456,12 +482,18 @@ zero.
|
|||||||
## Explicit imports
|
## Explicit imports
|
||||||
|
|
||||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
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
|
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
|
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`.
|
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
|
Path declarations use slash-separated relative paths. Absolute paths and network
|
||||||
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
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
|
and files are traversed depth-first in declaration order: a file contributes its own
|
||||||
@@ -488,7 +520,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
|
`BaseDir`. Whenever byte and reader sources supply filesystem context, they must
|
||||||
provide both `BaseDir` and `Root` as absolute existing directories; their diagnostic
|
provide both `BaseDir` and `Root` as absolute existing directories; their diagnostic
|
||||||
labels, including URI-looking labels, never imply filesystem paths. The base and
|
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
|
checks containment with relative paths, and opens the canonical root-relative target
|
||||||
through a library-owned root handle. Contained relative or absolute symlink targets
|
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
|
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
|
||||||
@@ -570,7 +604,9 @@ Unexported fields and `yaml:"-"` are ignored. `omitempty` is accepted but does n
|
|||||||
change binding or projection. An explicit `yaml:",inline"` flattens a struct
|
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
|
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
|
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
|
named fields unless explicitly inlined. `configkit:"secret"` is the only current
|
||||||
ConfigKit field tag and marks its bound field or container for secret classification
|
ConfigKit field tag and marks its bound field or container for secret classification
|
||||||
and redacted inspection.
|
and redacted inspection.
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
# Development
|
# Development
|
||||||
|
|
||||||
This is the contributor entry point for ConfigKit. Read the task-specific owners
|
Start here when contributing to ConfigKit. Read the task-specific owners below
|
||||||
below before making changes. Its public
|
before making changes. The [component overview](internal/overview.md) maps
|
||||||
loader currently covers strict selected sources, explicit path/glob import graphs,
|
implementation responsibilities; the [configuration contract](config.md) owns
|
||||||
composition, selected profile overlays, typed defaults, schema-version checks,
|
public behavior.
|
||||||
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.
|
|
||||||
|
|
||||||
## Initial orientation
|
## Initial orientation
|
||||||
|
|
||||||
1. Inspect the working tree and preserve unrelated changes.
|
1. Inspect the working tree and preserve unrelated changes.
|
||||||
2. Read the architecture policy for code or design work.
|
2. Read the architecture policy for code or design work.
|
||||||
3. Read the policy, contract, and internal documents applicable to the task.
|
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
|
## Task-specific reading guide
|
||||||
|
|
||||||
@@ -23,23 +18,29 @@ redacted effective rendering and historical value explanation. The
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Repository orientation or component responsibility | [Architecture policy](policy/architecture.md) and [component overview](internal/overview.md) |
|
| 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 |
|
| 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 |
|
| 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 owning contracts/tests as they are introduced |
|
| 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) |
|
| 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:
|
Accepted decisions:
|
||||||
|
|
||||||
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
|
- [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)
|
- [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)
|
- [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)
|
- [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)
|
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. The
|
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||||
[implementation plan](roadmap/implementation.md) owns the ordered stages and their
|
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||||
acceptance checks and recorded implementation evidence.
|
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).
|
||||||
|
|
||||||
## Baseline validation
|
## Baseline validation
|
||||||
|
|
||||||
@@ -51,27 +52,51 @@ environment:
|
|||||||
go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the library baseline from the repository root:
|
Run the everyday check with Bash 3.2 or newer and Git:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./...
|
./scripts/check.sh
|
||||||
go vet ./...
|
|
||||||
go build ./...
|
|
||||||
git diff --check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The default tests must remain deterministic and runnable without network access or
|
[The script](../scripts/check.sh) runs from the repository root regardless of the
|
||||||
credentials after dependency acquisition. Also inspect new untracked files, verify
|
invocation directory. It checks Go formatting without rewriting files, checks
|
||||||
changed local links and heading fragments, and review paths, examples, ownership,
|
staged/unstaged and untracked whitespace, then runs `go test ./...`,
|
||||||
and current-versus-planned claims. The commands above do not check links or
|
`go vet ./...`, and `go build ./...`, stopping on the first failure. It uses the
|
||||||
untracked content. Do not use an application binary build as the library baseline.
|
installed Go toolchain by default, honors an explicit `GOTOOLCHAIN`, and disables
|
||||||
Add executable-example and other checks when those artifacts are introduced; do
|
dependency downloads. Acquire dependencies and any selected toolchain beforehand.
|
||||||
not document nonexistent tools as operational.
|
Format reported Go files with `gofmt -w <files>`.
|
||||||
|
|
||||||
Once code exists, use focused checks while iterating and validation proportionate
|
The default suite includes executable examples, maintained consumer fixtures,
|
||||||
to the change under the testing policy. Documentation-only work does not require
|
local documentation-link checks, and the dependency import-boundary check.
|
||||||
Go checks unless it changes behavior-bearing examples or commands they validate.
|
It must stay deterministic and runnable without network access or credentials.
|
||||||
Release validation is a separate, stricter gate governed by release policy.
|
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
|
## 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.
|
Canonical paths—not `os.SameFile`—define identity, so hard links remain distinct.
|
||||||
|
|
||||||
Path-import implementation record (2026-09-06): `splitImportDirectives`,
|
Path-import implementation record (2026-09-06): `splitImportDirectives`,
|
||||||
`parseImportDeclaration`, `importFilesystem`, `importTraversal.visitPath`, and
|
`parseImportDeclaration` (now in `directives.go`), `importFilesystem` (now in
|
||||||
`loadImportedDocuments` in `imports.go` adapt the assigned envelope, loading, source,
|
`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
|
and failure mechanics. The corresponding ConfigKit tests adapt the assigned
|
||||||
declaration-order, disjoint composition, keyed-entry, all-source conflict, unsafe
|
declaration-order, disjoint composition, keyed-entry, all-source conflict, unsafe
|
||||||
target, alias, imported parse/schema, and relative-resolution cases. ConfigKit adds
|
target, alias, imported parse/schema, and relative-resolution cases. ConfigKit adds
|
||||||
|
|||||||
@@ -1,32 +1,43 @@
|
|||||||
# Internal Component Overview
|
# Internal Component Overview
|
||||||
|
|
||||||
ConfigKit currently contains the root `configkit` package, which establishes the
|
ConfigKit exposes the root `configkit` package and keeps its document and YAML
|
||||||
module and package documentation boundary. Its implemented public primitives are:
|
representations private. The implemented components and their owners are:
|
||||||
|
|
||||||
- immutable semantic `Path` construction and strict JSON Pointer parsing;
|
- 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;
|
- canonical, control-safe diagnostic rendering and structured `LoadError` access;
|
||||||
- opaque file, byte, reader, and no-input source construction and bounded reads;
|
- opaque file, byte, reader, and no-input source construction in `source.go`,
|
||||||
- a parser-independent ordered tree in `internal/document`;
|
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
|
- the sole upstream YAML integration in `internal/yaml`, which translates validated
|
||||||
input into that owned tree and exposes no parser types outside its boundary;
|
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
|
- a private strict composer in `compose.go` that joins only disjoint document-tree
|
||||||
definitions, retains ordered claims and composite mapping origins, reports every
|
definitions, retains ordered claims and composite mapping origins, reports every
|
||||||
conflicting location, and never aliases an input tree;
|
conflicting location, and never aliases an input tree;
|
||||||
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
|
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
|
||||||
with displacement records in `overlay.go`;
|
with displacement records in `overlay.go`; overlay records retain origins,
|
||||||
- detached parser-neutral CLI override validation and synthetic-source application
|
while value explanation uses separate history applications;
|
||||||
in `override.go`, sharing overlay, source, byte, node, and depth policies;
|
- 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
|
- a per-operation private reflection descriptor graph in `schema.go` for supported
|
||||||
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
|
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
|
||||||
- descriptor-driven canonical projection, detached typed cloning, cycle/limit
|
- descriptor-driven canonical projection in `projection.go`, detached typed
|
||||||
enforcement, and structural comparison in `projection.go`;
|
cloning with explicit secret-state modes in `clone.go`, shared reflection and
|
||||||
|
cycle/limit state in `value_operation.go`, and configuration-value comparison
|
||||||
|
in `document_compare.go`; validation separately audits sensitivity and secrets;
|
||||||
- strict schema-driven decoding over detached defaults in `decode.go`, including
|
- strict schema-driven decoding over detached defaults in `decode.go`, including
|
||||||
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
|
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
|
||||||
baseline/final trees, and initial default/input origins;
|
baseline/final trees, and initial default/input origins;
|
||||||
- public lifecycle coordination in `load.go`, bounded canonical import DFS in
|
- public lifecycle coordination in `load.go`, option selection in `options.go`,
|
||||||
`imports.go`, pattern-directed glob expansion in `glob.go`, version-report contexts
|
input assembly in `load_input.go`, typed phases/publication in `load_result.go`,
|
||||||
in `validation.go`, and detached layered metadata/history assembly in
|
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`;
|
`load_metadata.go`;
|
||||||
- recorded application defaulting and normalization in `resolution.go`, including
|
- recorded application defaulting and normalization in `resolution.go`, including
|
||||||
explicitness queries, helper verification, structural mutation audit, coalesced
|
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
|
- explicit bounded startup environment bootstrap and safe partial reporting in
|
||||||
`secrets.go`;
|
`secrets.go`;
|
||||||
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`;
|
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`;
|
||||||
- frozen-snapshot redaction and canonical effective output in `render.go`;
|
- canonical effective output in `render.go`, using frozen-snapshot redaction
|
||||||
- bounded immutable input applications, safe historical replay, detached explanation
|
shared with explanation in `snapshot.go`;
|
||||||
records, and deterministic human rendering in `explain.go`, with the
|
- detached public explanation records and coordination in `explain.go`, immutable
|
||||||
only upstream dumper integration isolated in `internal/yaml/write.go`;
|
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
|
- a separate maintained `examples/consumers` package with four sanitized consumer
|
||||||
shapes, fixtures, domain validators, and runtime-boundary functions; and
|
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.
|
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,
|
The [configuration contract](../config.md) owns implemented input, schema, decoding,
|
||||||
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
|
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
|
||||||
recorded resolution, typed secret references/bootstrap, semantic validation,
|
recorded resolution, typed secret references/bootstrap, semantic validation,
|
||||||
@@ -62,7 +83,7 @@ implementation:
|
|||||||
- [Extraction inventory](extraction.md)
|
- [Extraction inventory](extraction.md)
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md) owns binding boundaries and
|
The [architecture policy](../policy/architecture.md) owns binding boundaries and
|
||||||
invariants. The [initial target record](../roadmap/initial.md) links current feature
|
invariants. The [deferred scope](../roadmap/deferred.md) records deliberate
|
||||||
owners and preserves deferred scope; it is not an implementation inventory. See the
|
exclusions. See the
|
||||||
[development guide](../development.md) for contributor orientation and validation
|
[development guide](../development.md) for contributor orientation and validation
|
||||||
and the [release procedure](../release.md) for source-candidate gates.
|
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.
|
and resolution across consuming applications. This policy governs contributions.
|
||||||
The [component overview](../internal/overview.md) owns implementation inventory, the
|
The [component overview](../internal/overview.md) owns implementation inventory, the
|
||||||
[configuration contract](../config.md) owns implemented behavior, and 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
|
## Success criteria
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ for safe messages supplied by their hooks.
|
|||||||
Do not introduce automatic directory discovery, network providers, arbitrary
|
Do not introduce automatic directory discovery, network providers, arbitrary
|
||||||
merge strategies, additional configuration languages, or application runtime
|
merge strategies, additional configuration languages, or application runtime
|
||||||
management without concrete consumer evidence and an explicit design decision.
|
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
|
owns the deferred-feature inventory. Significant boundary
|
||||||
changes require an ADR and corresponding policy/contract updates; implementation
|
changes require an ADR and corresponding policy/contract updates; implementation
|
||||||
convenience alone is not a reason to broaden the public API.
|
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.
|
implementation; execution status belongs in roadmaps.
|
||||||
- Roadmaps own unimplemented targets, deferred work, and execution status.
|
- Roadmaps own unimplemented targets, deferred work, and execution status.
|
||||||
|
|
||||||
The initial target and implementation plan have separate owners. Do not put
|
Future implementation plans own their task order, acceptance gates, and progress;
|
||||||
staged steps or progress in `docs/roadmap/initial.md`. `docs/roadmap/implementation.md` owns those details.
|
`docs/roadmap/deferred.md` owns deliberate exclusions, not execution instructions.
|
||||||
As behavior lands, move its normative definition into the current contract and
|
As behavior lands, move its normative definition into the current contract and
|
||||||
replace the roadmap definition with a link. Retire completed roadmap material
|
replace the roadmap definition with a link. Retire completed roadmap material
|
||||||
as part of release preparation; ADRs preserve significant decision history.
|
as part of release preparation; ADRs preserve significant decision history.
|
||||||
@@ -56,7 +56,7 @@ handling, not secret values.
|
|||||||
|
|
||||||
| Topic | Owner | Boundary |
|
| 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 |
|
| 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 |
|
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
|
||||||
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
|
| 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 rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
|
||||||
| Release procedure | `docs/release.md` | Exact maintainer commands, prerequisites, supported targets, and verification |
|
| 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 |
|
| 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 |
|
| 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 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 |
|
| 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 |
|
| 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 |
|
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and the conditions for reconsidering them |
|
||||||
| Implementation plan | `docs/roadmap/implementation.md` | Stages, dependencies, acceptance gates, and progress |
|
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, task order, acceptance gates, and progress; completed plans retire to Git history |
|
||||||
| Release readiness evidence | `docs/roadmap/release-readiness.md` | Closed preparation gaps and outstanding candidate/publication evidence until roadmap retirement |
|
|
||||||
| Historical release notes | `docs/releases/` | Version-specific historical summaries required by release policy |
|
| 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 |
|
| 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
|
**Base Policy Version:** 0.1.0
|
||||||
|
|
||||||
**Last Updated:** 2026-09-06
|
**Last Updated:** 2026-09-07
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -220,23 +220,12 @@ restating or redefining its requirements.
|
|||||||
|
|
||||||
## Policy Exceptions
|
## 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
|
Candidate selection, publication, and post-publication verification remain
|
||||||
release candidate, version-matched release note, native macOS execution evidence, or
|
mandatory steps in the [release procedure](../release.md).
|
||||||
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.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ and macOS; cross-compilation is not native execution evidence.
|
|||||||
|
|
||||||
## 1. Prepare access and tools
|
## 1. Prepare access and tools
|
||||||
|
|
||||||
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash,
|
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash 3.2 or newer,
|
||||||
and two supported Go toolchains available. Configure private-host Git and Go module
|
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,
|
access through the normal credential manager before starting. Keep tokens, passwords,
|
||||||
SSH material, and environment dumps outside the repository and command transcript.
|
SSH material, and environment dumps outside the repository and command transcript.
|
||||||
Verify access without displaying credentials:
|
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.
|
||||||
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
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
_configkit:
|
configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: artifacts.yml
|
- path: artifacts.yml
|
||||||
poll_interval: 1m
|
poll_interval: 1m
|
||||||
|
|||||||
443
explain.go
443
explain.go
@@ -1,23 +1,6 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
"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")
|
|
||||||
)
|
|
||||||
|
|
||||||
// ValueSnapshot is a detached, deterministic YAML representation of one value.
|
// ValueSnapshot is a detached, deterministic YAML representation of one value.
|
||||||
// Present distinguishes absence or deletion from an explicit null value.
|
// Present distinguishes absence or deletion from an explicit null value.
|
||||||
@@ -51,42 +34,10 @@ type Explanation struct {
|
|||||||
SecretReference *SecretReferenceInfo
|
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
|
// Explain returns safe effective and chronological values for a known final or
|
||||||
// historical path. Unknown and malformed paths return false.
|
// historical path. Unknown and malformed paths return false.
|
||||||
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
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
|
return Explanation{}, false
|
||||||
}
|
}
|
||||||
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
|
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 {
|
if !relevant {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
after := valueSnapshot(working, path, r.secrets)
|
||||||
|
if !before.Present && !after.Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
Before: before,
|
Before: before,
|
||||||
After: valueSnapshot(working, path, r.secrets),
|
After: after,
|
||||||
Origin: historyApplicationOrigin(application, operationPath),
|
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)
|
working = document.Clone(r.input)
|
||||||
for _, snapshot := range r.resolution {
|
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
|
||||||
relevant := pathsIntersect(snapshot.change.Path, path)
|
relevant := pathsIntersect(snapshot.change.Path, path)
|
||||||
var before ValueSnapshot
|
var before ValueSnapshot
|
||||||
if relevant {
|
if relevant {
|
||||||
@@ -128,9 +99,13 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
|||||||
if !relevant {
|
if !relevant {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
after := valueSnapshot(working, path, r.secrets)
|
||||||
|
if !before.Present && !after.Present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
Before: before,
|
Before: before,
|
||||||
After: valueSnapshot(working, path, r.secrets),
|
After: after,
|
||||||
Origin: cloneOrigin(snapshot.change.Origin),
|
Origin: cloneOrigin(snapshot.change.Origin),
|
||||||
Reasons: append([]string(nil), snapshot.change.Reasons...),
|
Reasons: append([]string(nil), snapshot.change.Reasons...),
|
||||||
Dependencies: clonePaths(snapshot.change.Dependencies),
|
Dependencies: clonePaths(snapshot.change.Dependencies),
|
||||||
@@ -145,363 +120,3 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
|||||||
}
|
}
|
||||||
return explanation, true
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ type explanationConcurrency struct {
|
|||||||
|
|
||||||
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
||||||
directory := t.TempDir()
|
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, "transcription.yml", "transcription:\n concurrency: 8\n")
|
||||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
|
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
|
||||||
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
||||||
@@ -326,3 +326,87 @@ func historyValueLayers(explanation Explanation) []Layer {
|
|||||||
}
|
}
|
||||||
return layers
|
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 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) {
|
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- glob: fragments/*.yml
|
- glob: fragments/*.yml
|
||||||
- path: after.yml
|
- path: after.yml
|
||||||
items: {root: root}
|
items: {root: root}
|
||||||
`)
|
`)
|
||||||
aPath := writeImportFile(t, directory, "fragments/a.yml", `_configkit:
|
aPath := writeImportFile(t, directory, "fragments/a.yml", `configkit:
|
||||||
imports: [{path: nested/child.yml}]
|
imports: [{path: nested/child.yml}]
|
||||||
items: {alpha: alpha}
|
items: {alpha: alpha}
|
||||||
`)
|
`)
|
||||||
@@ -56,7 +56,7 @@ items: {alpha: alpha}
|
|||||||
|
|
||||||
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
|
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- glob: 'conf.d/literal\*.yml'
|
- glob: 'conf.d/literal\*.yml'
|
||||||
- glob: 'conf.d/item-[ab].yml'
|
- glob: 'conf.d/item-[ab].yml'
|
||||||
@@ -126,7 +126,7 @@ func TestLoadGlobImportsPreserveIdentityAndFileRules(t *testing.T) {
|
|||||||
t.Run("overlapping declarations", func(t *testing.T) {
|
t.Run("overlapping declarations", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
|
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:
|
imports:
|
||||||
- glob: conf/*.yml
|
- glob: conf/*.yml
|
||||||
- glob: conf/value.*
|
- glob: conf/value.*
|
||||||
@@ -269,5 +269,5 @@ func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) strin
|
|||||||
if optional {
|
if optional {
|
||||||
optionalLine = "\n optional: true"
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -36,13 +35,6 @@ func defaultImportLimits() importLimits {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type importDeclaration struct {
|
|
||||||
path string
|
|
||||||
glob string
|
|
||||||
optional bool
|
|
||||||
location Location
|
|
||||||
}
|
|
||||||
|
|
||||||
type importRoute struct {
|
type importRoute struct {
|
||||||
canonical string
|
canonical string
|
||||||
locations []Location
|
locations []Location
|
||||||
@@ -121,120 +113,6 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
|
|||||||
return composed, cloneSourceInfos(state.sources), diagnostics
|
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 {
|
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||||
if s.limits.sources < 1 {
|
if s.limits.sources < 1 {
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
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,
|
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
||||||
})
|
})
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return importParseDiagnostics(sourceID, issues)
|
return parserDiagnostics(sourceID, issues)
|
||||||
}
|
}
|
||||||
nodeCount := countInputNodes(root)
|
nodeCount := countInputNodes(root)
|
||||||
if s.usedNodes+nodeCount > s.limits.nodes {
|
if s.usedNodes+nodeCount > s.limits.nodes {
|
||||||
@@ -382,35 +260,12 @@ func (s *importTraversal) readImport(relative, sourceID string, optional bool) (
|
|||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
file, err := s.root.Open(relative)
|
data, failure := readRegularFile(relative, s.root.Stat, s.root.Open, limit)
|
||||||
if err != nil {
|
if failure != nil {
|
||||||
if optional && errors.Is(err, fs.ErrNotExist) {
|
if optional && failure.missingTarget {
|
||||||
return nil, true, nil
|
return nil, true, nil
|
||||||
}
|
}
|
||||||
value := classifySourceError(err, sourceID)
|
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
||||||
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})
|
|
||||||
return nil, false, &value
|
return nil, false, &value
|
||||||
}
|
}
|
||||||
s.usedBytes += int64(len(data))
|
s.usedBytes += int64(len(data))
|
||||||
@@ -428,184 +283,6 @@ func (s *importTraversal) currentRoute(location Location) importRoute {
|
|||||||
return importRoute{locations: locations}
|
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 {
|
func sourceInfos(source *SourceInfo) []SourceInfo {
|
||||||
if source == nil {
|
if source == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -613,29 +290,10 @@ func sourceInfos(source *SourceInfo) []SourceInfo {
|
|||||||
return []SourceInfo{cloneSourceInfo(*source)}
|
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 {
|
func importDiagnostic(code Code, message string, location *Location) Diagnostic {
|
||||||
return Diagnostic{Code: code, Severity: SeverityError, Message: message, Primary: cloneLocation(location)}
|
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 {
|
func importCycleDiagnostic(target string, location Location, cycle []importRoute) Diagnostic {
|
||||||
paths := make([]string, 0, len(cycle)+1)
|
paths := make([]string, 0, len(cycle)+1)
|
||||||
related := make([]Location, 0, len(cycle))
|
related := make([]Location, 0, len(cycle))
|
||||||
|
|||||||
@@ -22,16 +22,37 @@ type importTestConfig struct {
|
|||||||
Items map[string]string `yaml:"items"`
|
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) {
|
func TestLoadTraversesPathImportsDepthFirstAndComposesOrigins(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: first.yml
|
- path: first.yml
|
||||||
- path: second.yml
|
- path: second.yml
|
||||||
settings:
|
settings:
|
||||||
root: root
|
root: root
|
||||||
`)
|
`)
|
||||||
firstPath := writeImportFile(t, directory, "first.yml", `_configkit:
|
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: nested/third.yml
|
- path: nested/third.yml
|
||||||
settings:
|
settings:
|
||||||
@@ -71,7 +92,7 @@ settings:
|
|||||||
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
||||||
t.Run("all duplicate claimants", func(t *testing.T) {
|
t.Run("all duplicate claimants", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: one.yml
|
- path: one.yml
|
||||||
- path: two.yml
|
- path: two.yml
|
||||||
@@ -97,7 +118,7 @@ func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
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)
|
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -114,15 +135,15 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
|||||||
body string
|
body string
|
||||||
code Code
|
code Code
|
||||||
}{
|
}{
|
||||||
{name: "directive kind", body: "_configkit: []\n", code: CodeImportInvalid},
|
{name: "directive kind", body: "configkit: []\n", code: CodeImportInvalid},
|
||||||
{name: "unknown directive field", body: "_configkit: {unknown: true}\n", code: CodeImportInvalid},
|
{name: "unknown directive field", body: "configkit: {unknown: true}\n", code: CodeImportInvalid},
|
||||||
{name: "imports kind", body: "_configkit: {imports: {}}\n", code: CodeImportInvalid},
|
{name: "imports kind", body: "configkit: {imports: {}}\n", code: CodeImportInvalid},
|
||||||
{name: "declaration kind", body: "_configkit: {imports: [file.yml]}\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: "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: "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: "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: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
||||||
{name: "nested directive", body: "nested: {_configkit: {}}\n", code: CodeImportInvalid},
|
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -136,7 +157,7 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
|||||||
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
||||||
t.Run("optional missing", func(t *testing.T) {
|
t.Run("optional missing", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: missing.yml
|
- path: missing.yml
|
||||||
optional: true
|
optional: true
|
||||||
@@ -172,7 +193,7 @@ settings: {root: retained}
|
|||||||
if test.setup != nil {
|
if test.setup != nil {
|
||||||
test.setup(t, directory)
|
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)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -181,9 +202,9 @@ settings: {root: retained}
|
|||||||
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
||||||
t.Run("cycle", func(t *testing.T) {
|
t.Run("cycle", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: a.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, "a.yml", "configkit:\n imports: [{path: b.yml}]\n")
|
||||||
writeImportFile(t, directory, "b.yml", "_configkit:\n imports: [{path: main.yml}]\n")
|
writeImportFile(t, directory, "b.yml", "configkit:\n imports: [{path: main.yml}]\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
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") {
|
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) {
|
t.Run("diamond", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: left.yml}, {path: right.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, "left.yml", "configkit:\n imports: [{path: shared.yml}]\n")
|
||||||
writeImportFile(t, directory, "right.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")
|
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
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 {
|
if err := os.Symlink("target.yml", filepath.Join(directory, "second-link.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
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)
|
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 {
|
if err := os.Symlink(target, filepath.Join(directory, "link.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
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})
|
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) {
|
if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) {
|
||||||
t.Fatalf("symlink result = %#v, %v", result, err)
|
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 {
|
if err := os.Symlink(target, filepath.Join(root, "escape.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
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)
|
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 {
|
if err := os.Link(one, filepath.Join(directory, "two.yml")); err != nil {
|
||||||
t.Skipf("hard links unavailable: %v", err)
|
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)
|
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 {
|
if err := os.Symlink("loop.yml", filepath.Join(directory, "loop.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
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)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -272,7 +293,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
|
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writeImportFile(t, directory, "fragment.yml", "settings: {first: imported}\n")
|
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}
|
context := &FilesystemContext{BaseDir: directory, Root: directory}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -295,7 +316,7 @@ func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T)
|
|||||||
|
|
||||||
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
||||||
root := t.TempDir()
|
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")
|
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
|
||||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
|
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||||
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
||||||
@@ -309,7 +330,7 @@ func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
|||||||
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
||||||
t.Run("parse", func(t *testing.T) {
|
t.Run("parse", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
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")
|
writeImportFile(t, directory, "broken.yml", "settings: [\n")
|
||||||
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
|
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
diagnostics := loadError.Diagnostics()
|
diagnostics := loadError.Diagnostics()
|
||||||
@@ -320,7 +341,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("decode", func(t *testing.T) {
|
t.Run("decode", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
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")
|
writeImportFile(t, directory, "unknown.yml", "unknown: value\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -332,7 +353,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
|||||||
|
|
||||||
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
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)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||||
|
|
||||||
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
||||||
@@ -344,13 +365,13 @@ func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
|||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
t.Skip("filesystem does not enforce the unreadable-file fixture")
|
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)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
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, "one.yml", "settings: {first: one}\n")
|
||||||
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
|
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
|
||||||
main := parseImportMain(t, mainPath)
|
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")
|
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
||||||
main = parseImportMain(t, mainPath)
|
main = parseImportMain(t, mainPath)
|
||||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
_, _, 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) {
|
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
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")
|
importedPath := writeImportFile(t, directory, "imported.yml", "settings: {first: imported}\n")
|
||||||
main := parseImportMain(t, mainPath)
|
main := parseImportMain(t, mainPath)
|
||||||
imported := parseImportMain(t, importedPath)
|
imported := parseImportMain(t, importedPath)
|
||||||
|
|||||||
@@ -32,11 +32,14 @@ type Entry struct {
|
|||||||
// Node is ConfigKit's owned configuration representation. Number Text retains its
|
// Node is ConfigKit's owned configuration representation. Number Text retains its
|
||||||
// exact validated decimal spelling; Boolean Text is "true" or "false".
|
// exact validated decimal spelling; Boolean Text is "true" or "false".
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Kind Kind
|
// Sensitive preserves field classification from concrete typed projections.
|
||||||
Location Location
|
// It is metadata, not part of the configuration value or emitted YAML.
|
||||||
Text string
|
Sensitive bool
|
||||||
Mapping []Entry
|
Kind Kind
|
||||||
Sequence []*Node
|
Location Location
|
||||||
|
Text string
|
||||||
|
Mapping []Entry
|
||||||
|
Sequence []*Node
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone returns a detached copy of a node tree.
|
// Clone returns a detached copy of a node tree.
|
||||||
|
|||||||
330
load.go
330
load.go
@@ -72,6 +72,7 @@ type Result[T any] struct {
|
|||||||
diagnostics []Diagnostic
|
diagnostics []Diagnostic
|
||||||
baseline *document.Node
|
baseline *document.Node
|
||||||
input *document.Node
|
input *document.Node
|
||||||
|
inputOrigins map[string]Origin
|
||||||
final *document.Node
|
final *document.Node
|
||||||
resolution []resolutionSnapshot
|
resolution []resolutionSnapshot
|
||||||
applications []historyApplication
|
applications []historyApplication
|
||||||
@@ -113,332 +114,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) {
|
func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
|
||||||
accumulator := diagnosticAccumulator{}
|
accumulator := diagnosticAccumulator{}
|
||||||
options, diagnostics := selectOptions(source, supplied)
|
options, diagnostics := selectOptions(source, supplied)
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
accumulator.add(diagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
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 len(diagnostics) != 0 {
|
||||||
if parsed.source != nil {
|
|
||||||
accumulator.sources = append(accumulator.sources, *parsed.source)
|
|
||||||
}
|
|
||||||
accumulator.add(diagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
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]())
|
schema, schemaIssue := buildSchema(reflect.TypeFor[T]())
|
||||||
if schemaIssue != nil {
|
if schemaIssue != nil {
|
||||||
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
|
versionDiagnostics := checkVersion(options.Definition.Version, input.document)
|
||||||
versionDiagnostics := checkVersion(options.Definition.Version, inputDocument)
|
|
||||||
accumulator.add(versionDiagnostics...)
|
accumulator.add(versionDiagnostics...)
|
||||||
if hasErrors(versionDiagnostics) {
|
if hasErrors(versionDiagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
if activeProfile(options.Profile) {
|
input, diagnostics = applyInputLayers(source, options, input)
|
||||||
if composition == nil {
|
accumulator.sources = input.sources
|
||||||
composition, diagnostics = composeDocuments(inputDocument)
|
accumulator.add(diagnostics...)
|
||||||
if len(diagnostics) != 0 {
|
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))
|
|
||||||
}
|
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
if composition != nil {
|
|
||||||
applyCompositionOrigins(&decoded, composition.origins)
|
|
||||||
}
|
|
||||||
|
|
||||||
configuration := decoded.value.Interface().(T)
|
decoded, diagnostics := decodeLoadInput(schema, options.Definition, input)
|
||||||
metadata := metadataFromDecoded(decoded)
|
accumulator.add(diagnostics...)
|
||||||
if composition != nil {
|
if len(diagnostics) != 0 {
|
||||||
applyOverlayMetadata(&metadata, composition.overlays)
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
final := decoded.final
|
resolved, diagnostics := resolveLoadInput(schema, options.Definition, input, decoded)
|
||||||
var resolutionSnapshots []resolutionSnapshot
|
accumulator.add(diagnostics...)
|
||||||
if options.Definition.Resolve != nil {
|
if hasErrors(diagnostics) {
|
||||||
resolved := runResolution(&configuration, options.Definition.Resolve, schema, decoded, inputDocument)
|
return nil, accumulator.loadError()
|
||||||
accumulator.add(resolved.diagnostics...)
|
|
||||||
if hasErrors(resolved.diagnostics) {
|
|
||||||
return nil, accumulator.loadError()
|
|
||||||
}
|
|
||||||
final = resolved.final
|
|
||||||
resolutionSnapshots = resolved.snapshots
|
|
||||||
applyResolutionMetadata(&metadata, resolved)
|
|
||||||
}
|
}
|
||||||
applications := []historyApplication(nil)
|
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) {
|
||||||
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) {
|
|
||||||
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
||||||
return nil, accumulator.loadError()
|
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...)
|
accumulator.add(secretResolution.diagnostics...)
|
||||||
if hasErrors(secretResolution.diagnostics) {
|
if hasErrors(secretResolution.diagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
if options.Definition.Validate != nil {
|
if options.Definition.Validate != nil {
|
||||||
validationDiagnostics := runValidation(&configuration, options.Definition.Validate, schema, final, inputDocument, secretResolution.lookups)
|
diagnostics = runValidation(&configuration, options.Definition.Validate, schema, resolved.final, input.document, secretResolution.lookups)
|
||||||
accumulator.add(validationDiagnostics...)
|
accumulator.add(diagnostics...)
|
||||||
if hasErrors(validationDiagnostics) {
|
if hasErrors(diagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final, applications)
|
resolved.configuration = configuration
|
||||||
return &Result[T]{
|
// Publication detaches equal-valued validator replacements, preserving the
|
||||||
Config: configuration,
|
// validated secret state, then finalizes metadata and sensitivity.
|
||||||
metadata: metadata,
|
return publishLoadResult(schema, options.Definition, resolved, secretResolution.references, &accumulator)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
func cloneMetadata(metadata Metadata) Metadata {
|
||||||
if metadata.fields == nil && metadata.history == nil {
|
if metadata.fields == nil && metadata.history == nil {
|
||||||
return Metadata{}
|
return Metadata{}
|
||||||
@@ -184,6 +167,14 @@ func cloneMetadata(metadata Metadata) Metadata {
|
|||||||
fields: make(map[string]FieldInfo, len(metadata.fields)),
|
fields: make(map[string]FieldInfo, len(metadata.fields)),
|
||||||
history: make(map[string][]Change, len(metadata.history)),
|
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 {
|
for path, info := range metadata.fields {
|
||||||
cloned.fields[path] = cloneFieldInfo(info)
|
cloned.fields[path] = cloneFieldInfo(info)
|
||||||
}
|
}
|
||||||
|
|||||||
115
load_result.go
Normal file
115
load_result.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
decoded, decodeIssues := decodeConfiguration(schema, defaults, input.document)
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -240,7 +240,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 {
|
if result != nil || err == nil {
|
||||||
t.Fatalf("result, err = %#v, %v", result, err)
|
t.Fatalf("result, err = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
@@ -249,7 +249,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
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)
|
t.Fatalf("diagnostic = %#v", diagnostic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
metadata.go
101
metadata.go
@@ -1,16 +1,24 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
// Metadata provides detached effective presence/origin and chronological history
|
// Metadata provides detached effective presence/origin and chronological history
|
||||||
// queries for a load. Its zero value contains no paths.
|
// queries for a load. Its zero value contains no paths.
|
||||||
type Metadata struct {
|
type Metadata struct {
|
||||||
fields map[string]FieldInfo
|
effectivePaths map[string]Path
|
||||||
history map[string][]Change
|
historicalPaths map[string][]Path
|
||||||
|
fields map[string]FieldInfo
|
||||||
|
history map[string][]Change
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup returns information for an effective path. Unknown and invalid paths
|
// Lookup returns information for an effective path. Unknown and invalid paths
|
||||||
// return false.
|
// return false.
|
||||||
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
|
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
|
||||||
if !path.valid() {
|
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) {
|
||||||
return FieldInfo{}, false
|
return FieldInfo{}, false
|
||||||
}
|
}
|
||||||
info, ok := m.fields[path.String()]
|
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.
|
// Removed historical paths can have history even though Lookup returns false.
|
||||||
// Unknown and invalid paths return nil.
|
// Unknown and invalid paths return nil.
|
||||||
func (m Metadata) History(path Path) []Change {
|
func (m Metadata) History(path Path) []Change {
|
||||||
if !path.valid() {
|
if !m.hasHistoricalPath(path) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
history, ok := m.history[path.String()]
|
history, ok := m.history[path.String()]
|
||||||
@@ -45,3 +53,88 @@ func (m Metadata) History(path Path) []Change {
|
|||||||
}
|
}
|
||||||
return cloned
|
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
|
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) {
|
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
path := Keys("service", "workers")
|
path := Keys("service", "workers")
|
||||||
metadata := Metadata{
|
metadata := Metadata{
|
||||||
|
effectivePaths: map[string]Path{path.String(): path},
|
||||||
|
historicalPaths: map[string][]Path{path.String(): {path}},
|
||||||
fields: map[string]FieldInfo{
|
fields: map[string]FieldInfo{
|
||||||
path.String(): {
|
path.String(): {
|
||||||
Explicit: true,
|
Explicit: true,
|
||||||
@@ -56,3 +147,45 @@ func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
|
|||||||
t.Fatal("invalid path produced metadata")
|
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 {
|
type overlayRecord struct {
|
||||||
change Change
|
change Change
|
||||||
displaced []displacedDefinition
|
displaced []displacedDefinition
|
||||||
before *document.Node
|
|
||||||
after *document.Node
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type displacedDefinition struct {
|
type displacedDefinition struct {
|
||||||
@@ -26,15 +24,7 @@ func overlayDocuments(base *composedDocument, overlay *document.Node) (*composed
|
|||||||
Message: "profile overlay requires mapping document roots",
|
Message: "profile overlay requires mapping document roots",
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
result := &composedDocument{
|
result := cloneComposedDocument(base)
|
||||||
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,
|
|
||||||
}
|
|
||||||
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
|
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
|
||||||
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
|
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
|
||||||
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
|
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),
|
Previous: previousOrigins(previous),
|
||||||
},
|
},
|
||||||
displaced: displaced,
|
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))
|
cloned := make([]overlayRecord, len(records))
|
||||||
for index, record := range records {
|
for index, record := range records {
|
||||||
cloned[index].change = cloneChange(record.change)
|
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))
|
cloned[index].displaced = make([]displacedDefinition, len(record.displaced))
|
||||||
for displacedIndex, displaced := range record.displaced {
|
for displacedIndex, displaced := range record.displaced {
|
||||||
cloned[index].displaced[displacedIndex] = displacedDefinition{
|
cloned[index].displaced[displacedIndex] = displacedDefinition{
|
||||||
|
|||||||
247
override.go
247
override.go
@@ -1,9 +1,6 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
|
||||||
"reflect"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
@@ -22,17 +19,7 @@ type preparedOverride struct {
|
|||||||
nodes int
|
nodes int
|
||||||
}
|
}
|
||||||
|
|
||||||
type overrideValueBuilder struct {
|
func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) ([]preparedOverride, []SourceInfo, []Diagnostic) {
|
||||||
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
|
|
||||||
}
|
|
||||||
if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources {
|
if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources {
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{{
|
return nil, cloneSourceInfos(sources), []Diagnostic{{
|
||||||
Code: CodeOverrideLimitExceeded, Severity: SeverityError,
|
Code: CodeOverrideLimitExceeded, Severity: SeverityError,
|
||||||
@@ -147,13 +134,7 @@ func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Ov
|
|||||||
return nil, allSources, canonicalDiagnostics(diagnostics)
|
return nil, allSources, canonicalDiagnostics(diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := cloneComposedDocument(base)
|
return prepared, allSources, nil
|
||||||
for _, override := range prepared {
|
|
||||||
applyPreparedOverride(result, override)
|
|
||||||
}
|
|
||||||
result.inputBytes += usedBytes
|
|
||||||
result.inputNodes += usedNodes
|
|
||||||
return result, allSources, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validOverrideLabel(label string) bool {
|
func validOverrideLabel(label string) bool {
|
||||||
@@ -207,7 +188,7 @@ func overlappingOverride(previous []Override, valid []bool, path Path) int {
|
|||||||
|
|
||||||
func pathContainsDirective(path Path) bool {
|
func pathContainsDirective(path Path) bool {
|
||||||
for _, segment := range path.segments {
|
for _, segment := range path.segments {
|
||||||
if segment.key == "_configkit" {
|
if segment.key == directiveKey {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,7 +205,7 @@ func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
|
|||||||
if node.Kind == document.Mapping {
|
if node.Kind == document.Mapping {
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
child := path.Key(entry.Key)
|
child := path.Key(entry.Key)
|
||||||
if entry.Key == "_configkit" {
|
if entry.Key == directiveKey {
|
||||||
return child, true
|
return child, true
|
||||||
}
|
}
|
||||||
if found, ok := overrideDirectivePath(entry.Value, child); ok {
|
if found, ok := overrideDirectivePath(entry.Value, child); ok {
|
||||||
@@ -240,223 +221,3 @@ func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
|
|||||||
}
|
}
|
||||||
return Path{}, false
|
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) {
|
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
|
||||||
directory := t.TempDir()
|
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:
|
writeImportFile(t, directory, "base.yml", `settings:
|
||||||
target: base
|
target: base
|
||||||
count: 4
|
count: 4
|
||||||
@@ -187,8 +187,8 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
|
|||||||
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
|
{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: "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: "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 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 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", 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: "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},
|
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},
|
||||||
|
|||||||
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
|
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) {
|
func parseSource(source Source, sourceID string, limits parseLimits) (parsedSource, []Diagnostic) {
|
||||||
read, diagnostics := readSource(source, sourceID, limits.source)
|
read, diagnostics := readSource(source, sourceID, limits.source)
|
||||||
if len(diagnostics) != 0 || read.info == nil {
|
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)
|
root, issues := yamladapter.Parse(bytes.NewReader(read.data), sourceID, limits.document)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
diagnostics := make([]Diagnostic, len(issues))
|
return parsedSource{source: read.info, bytes: int64(len(read.data))}, parserDiagnostics(sourceID, 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{document: root, source: read.info, bytes: int64(len(read.data))}, nil
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
||||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,9 +66,14 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS
|
|||||||
value.Primary = nil
|
value.Primary = nil
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{value}
|
return nil, cloneSourceInfos(sources), []Diagnostic{value}
|
||||||
}
|
}
|
||||||
|
rootClosed := false
|
||||||
|
defer func() {
|
||||||
|
if !rootClosed {
|
||||||
|
_ = root.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
relative, err := filepath.Rel(rootPath, canonical)
|
relative, err := filepath.Rel(rootPath, canonical)
|
||||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
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)}
|
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)
|
sources = append(cloneSourceInfos(sources), profileSource)
|
||||||
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
|
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
_ = root.Close()
|
|
||||||
return nil, sources, []Diagnostic{*diagnostic}
|
return nil, sources, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
|
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
_ = root.Close()
|
return nil, sources, parserDiagnostics(sourceID, issues)
|
||||||
return nil, sources, importParseDiagnostics(sourceID, issues)
|
|
||||||
}
|
}
|
||||||
nodeCount := countInputNodes(profileRoot)
|
nodeCount := countInputNodes(profileRoot)
|
||||||
if base.inputNodes+nodeCount > limits.nodes {
|
if base.inputNodes+nodeCount > limits.nodes {
|
||||||
_ = root.Close()
|
|
||||||
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
|
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
|
||||||
}
|
}
|
||||||
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
|
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
|
||||||
_ = root.Close()
|
|
||||||
return nil, sources, diagnostics
|
return nil, sources, diagnostics
|
||||||
}
|
}
|
||||||
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
|
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
|
||||||
location := publicLocation(versionNode.Location)
|
location := publicLocation(versionNode.Location)
|
||||||
_ = root.Close()
|
|
||||||
return nil, sources, []Diagnostic{{
|
return nil, sources, []Diagnostic{{
|
||||||
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location,
|
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)}
|
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 {
|
if limit < 0 {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
file, err := root.Open(relative)
|
data, failure := readRegularFile(relative, root.Stat, root.Open, limit)
|
||||||
if err != nil {
|
if failure != nil {
|
||||||
value := classifySourceError(err, sourceID)
|
value := sourceReadDiagnostic(failure, sourceID, CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit")
|
||||||
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})
|
|
||||||
return nil, &value
|
return nil, &value
|
||||||
}
|
}
|
||||||
return data, nil
|
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) {
|
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports: [{path: base.yml}]
|
imports: [{path: base.yml}]
|
||||||
settings:
|
settings:
|
||||||
kept: main
|
kept: main
|
||||||
@@ -133,8 +133,8 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
|||||||
profile string
|
profile string
|
||||||
code Code
|
code Code
|
||||||
}{
|
}{
|
||||||
{name: "root directive", profile: "_configkit: {}\n", code: CodeProfileDirective},
|
{name: "root directive", profile: "configkit: {}\n", code: CodeProfileDirective},
|
||||||
{name: "nested directive", profile: "settings: {_configkit: {}}\n", code: CodeProfileDirective},
|
{name: "nested directive", profile: "settings: {configkit: {}}\n", code: CodeProfileDirective},
|
||||||
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
|
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
|
||||||
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
||||||
} {
|
} {
|
||||||
@@ -158,7 +158,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("profile aliases imported base", func(t *testing.T) {
|
t.Run("profile aliases imported base", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
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")
|
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
|
||||||
})
|
})
|
||||||
|
|||||||
379
projection.go
379
projection.go
@@ -1,6 +1,7 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"encoding"
|
"encoding"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -12,34 +13,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
type projectionLimits struct {
|
|
||||||
depth int
|
|
||||||
nodes int
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultProjectionLimits() projectionLimits {
|
|
||||||
return projectionLimits{depth: maxDocumentDepth, nodes: maxDocumentNodes}
|
|
||||||
}
|
|
||||||
|
|
||||||
type projectionIssue struct {
|
|
||||||
code Code
|
|
||||||
path Path
|
|
||||||
message string
|
|
||||||
}
|
|
||||||
|
|
||||||
type valueVisit struct {
|
|
||||||
typeOf reflect.Type
|
|
||||||
kind reflect.Kind
|
|
||||||
ptr uintptr
|
|
||||||
}
|
|
||||||
|
|
||||||
type valueOperation struct {
|
|
||||||
schema *schemaDescriptor
|
|
||||||
limits projectionLimits
|
|
||||||
active map[valueVisit]bool
|
|
||||||
nodes int
|
|
||||||
}
|
|
||||||
|
|
||||||
func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (*document.Node, *projectionIssue) {
|
func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (*document.Node, *projectionIssue) {
|
||||||
if schema == nil || schema.root == nil || !value.IsValid() || value.Type() != schema.root.typeOf {
|
if schema == nil || schema.root == nil || !value.IsValid() || value.Type() != schema.root.typeOf {
|
||||||
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
|
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
|
||||||
@@ -48,14 +21,6 @@ func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits
|
|||||||
return operation.project(value, schema.root, Path{}, 0)
|
return operation.project(value, schema.root, Path{}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (reflect.Value, *projectionIssue) {
|
|
||||||
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) {
|
func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
if issue := o.checkDepth(path, depth); issue != nil {
|
if issue := o.checkDepth(path, depth); issue != nil {
|
||||||
return nil, issue
|
return nil, issue
|
||||||
@@ -117,6 +82,15 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
|||||||
seen[member.name] = true
|
seen[member.name] = true
|
||||||
result.Mapping = append(result.Mapping, document.Entry{Key: member.name, Value: child})
|
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
|
return result, nil
|
||||||
case descriptorMap:
|
case descriptorMap:
|
||||||
if value.IsNil() {
|
if value.IsNil() {
|
||||||
@@ -134,7 +108,7 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
|||||||
result.Mapping = make([]document.Entry, 0, value.Len())
|
result.Mapping = make([]document.Entry, 0, value.Len())
|
||||||
keys := value.MapKeys()
|
keys := value.MapKeys()
|
||||||
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
||||||
return cmpString(left.String(), right.String())
|
return cmp.Compare(left.String(), right.String())
|
||||||
})
|
})
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
childPath := path.Key(key.String())
|
childPath := path.Key(key.String())
|
||||||
@@ -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) {
|
func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
if issue := o.checkDepth(path, depth); issue != nil {
|
if issue := o.checkDepth(path, depth); issue != nil {
|
||||||
return nil, issue
|
return nil, issue
|
||||||
@@ -416,64 +227,6 @@ func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*docume
|
|||||||
return &document.Node{Kind: kind}, nil
|
return &document.Node{Kind: kind}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *valueOperation) charge(path Path) *projectionIssue {
|
|
||||||
o.nodes++
|
|
||||||
if o.limits.nodes >= 0 && o.nodes > o.limits.nodes {
|
|
||||||
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the node limit"}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *valueOperation) checkDepth(path Path, depth int) *projectionIssue {
|
|
||||||
if depth > o.limits.depth {
|
|
||||||
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the depth limit"}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *valueOperation) enter(value reflect.Value, path Path) (func(), *projectionIssue) {
|
|
||||||
visit, ok := visitFor(value)
|
|
||||||
if !ok {
|
|
||||||
return func() {}, nil
|
|
||||||
}
|
|
||||||
if o.active[visit] {
|
|
||||||
return nil, &projectionIssue{code: CodeSchemaCycle, path: path.clone(), message: "configuration values must not contain cycles"}
|
|
||||||
}
|
|
||||||
o.active[visit] = true
|
|
||||||
return func() { delete(o.active, visit) }, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func visitFor(value reflect.Value) (valueVisit, bool) {
|
|
||||||
if isNilValue(value) {
|
|
||||||
return valueVisit{}, false
|
|
||||||
}
|
|
||||||
visit := valueVisit{typeOf: value.Type(), kind: value.Kind()}
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Pointer, reflect.Slice:
|
|
||||||
if value.Kind() == reflect.Slice && value.Len() == 0 {
|
|
||||||
return valueVisit{}, false
|
|
||||||
}
|
|
||||||
visit.ptr = value.Pointer()
|
|
||||||
case reflect.Map:
|
|
||||||
if value.Len() == 0 {
|
|
||||||
return valueVisit{}, false
|
|
||||||
}
|
|
||||||
visit.ptr = uintptr(value.UnsafePointer())
|
|
||||||
default:
|
|
||||||
return valueVisit{}, false
|
|
||||||
}
|
|
||||||
return visit, visit.ptr != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func isNilValue(value reflect.Value) bool {
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
|
||||||
return value.IsNil()
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIssue) {
|
func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIssue) {
|
||||||
var marshaler encoding.TextMarshaler
|
var marshaler encoding.TextMarshaler
|
||||||
if value.CanInterface() {
|
if value.CanInterface() {
|
||||||
@@ -493,113 +246,3 @@ func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIs
|
|||||||
}
|
}
|
||||||
return string(text), nil
|
return string(text), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmpString(left, right string) int {
|
|
||||||
switch {
|
|
||||||
case left < right:
|
|
||||||
return -1
|
|
||||||
case left > right:
|
|
||||||
return 1
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type documentDeltaKind uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
documentAdded documentDeltaKind = iota + 1
|
|
||||||
documentRemoved
|
|
||||||
documentReplaced
|
|
||||||
)
|
|
||||||
|
|
||||||
type documentDelta struct {
|
|
||||||
path Path
|
|
||||||
kind documentDeltaKind
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareDocumentTrees(before, after *document.Node) []documentDelta {
|
|
||||||
var deltas []documentDelta
|
|
||||||
compareDocumentNode(before, after, Path{}, &deltas)
|
|
||||||
slices.SortFunc(deltas, func(left, right documentDelta) int {
|
|
||||||
if order := cmpString(left.path.String(), right.path.String()); order != 0 {
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
return int(left.kind) - int(right.kind)
|
|
||||||
})
|
|
||||||
return deltas
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareDocumentNode(before, after *document.Node, path Path, deltas *[]documentDelta) {
|
|
||||||
if before == nil && after == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if before == nil {
|
|
||||||
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentAdded})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if after == nil {
|
|
||||||
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentRemoved})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if before.Kind != after.Kind {
|
|
||||||
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch before.Kind {
|
|
||||||
case document.Mapping:
|
|
||||||
beforeEntries := make(map[string]*document.Node, len(before.Mapping))
|
|
||||||
afterEntries := make(map[string]*document.Node, len(after.Mapping))
|
|
||||||
keys := make(map[string]bool, len(before.Mapping)+len(after.Mapping))
|
|
||||||
for _, entry := range before.Mapping {
|
|
||||||
beforeEntries[entry.Key] = entry.Value
|
|
||||||
keys[entry.Key] = true
|
|
||||||
}
|
|
||||||
for _, entry := range after.Mapping {
|
|
||||||
afterEntries[entry.Key] = entry.Value
|
|
||||||
keys[entry.Key] = true
|
|
||||||
}
|
|
||||||
ordered := make([]string, 0, len(keys))
|
|
||||||
for key := range keys {
|
|
||||||
ordered = append(ordered, key)
|
|
||||||
}
|
|
||||||
slices.Sort(ordered)
|
|
||||||
for _, key := range ordered {
|
|
||||||
compareDocumentNode(beforeEntries[key], afterEntries[key], path.Key(key), deltas)
|
|
||||||
}
|
|
||||||
case document.Sequence:
|
|
||||||
if !documentNodesEqual(before, after) {
|
|
||||||
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
if before.Text != after.Text {
|
|
||||||
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func documentNodesEqual(left, right *document.Node) bool {
|
|
||||||
if left == nil || right == nil {
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
if left.Kind != right.Kind || left.Text != right.Text || len(left.Mapping) != len(right.Mapping) || len(left.Sequence) != len(right.Sequence) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if left.Kind == document.Mapping {
|
|
||||||
leftMap := make(map[string]*document.Node, len(left.Mapping))
|
|
||||||
for _, entry := range left.Mapping {
|
|
||||||
leftMap[entry.Key] = entry.Value
|
|
||||||
}
|
|
||||||
for _, entry := range right.Mapping {
|
|
||||||
if !documentNodesEqual(leftMap[entry.Key], entry.Value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := range left.Sequence {
|
|
||||||
if !documentNodesEqual(left.Sequence[i], right.Sequence[i]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
|
|||||||
if issue != nil {
|
if issue != nil {
|
||||||
t.Fatal(issue.message)
|
t.Fatal(issue.message)
|
||||||
}
|
}
|
||||||
deltas := compareDocumentTrees(before, after)
|
deltas := compareConfigurationValues(before, after)
|
||||||
want := []struct {
|
want := []struct {
|
||||||
path string
|
path string
|
||||||
kind documentDeltaKind
|
kind documentDeltaKind
|
||||||
@@ -352,7 +352,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
|
|||||||
t.Fatal("sequence comparison created index correspondence")
|
t.Fatal("sequence comparison created index correspondence")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if equal := compareDocumentTrees(after, document.Clone(after)); len(equal) != 0 {
|
if equal := compareConfigurationValues(after, document.Clone(after)); len(equal) != 0 {
|
||||||
t.Fatalf("equal trees changed = %#v", equal)
|
t.Fatalf("equal trees changed = %#v", equal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type layeredConfig struct {
|
|||||||
|
|
||||||
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
|
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports: [{path: imported.yml}]
|
imports: [{path: imported.yml}]
|
||||||
shared: {equal: same}
|
shared: {equal: same}
|
||||||
values: [1, 2]
|
values: [1, 2]
|
||||||
@@ -138,7 +138,7 @@ empty: {}
|
|||||||
t.Fatal(issue)
|
t.Fatal(issue)
|
||||||
}
|
}
|
||||||
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
|
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
|
||||||
if projectionIssue != nil || !documentNodesEqual(projected, result.final) {
|
if projectionIssue != nil || !configurationValuesEqual(projected, result.final) {
|
||||||
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
|
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ const (
|
|||||||
LayerResolution Layer = "resolution"
|
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.
|
// used for input and default origins.
|
||||||
type ChangeKind string
|
type ChangeKind string
|
||||||
|
|
||||||
const (
|
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"
|
ChangeDerivedDefault ChangeKind = "derived_default"
|
||||||
ChangeNormalization ChangeKind = "normalization"
|
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")
|
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
||||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||||
}
|
}
|
||||||
deltas := compareDocumentTrees(decoded.final, after)
|
deltas := compareConfigurationValues(decoded.final, after)
|
||||||
ordered := orderedResolutionRecords(state.records)
|
ordered := orderedResolutionRecords(state.records)
|
||||||
for _, delta := range deltas {
|
for _, delta := range deltas {
|
||||||
if coveringResolutionRecord(ordered, delta.path) == nil {
|
if coveringResolutionRecord(ordered, delta.path) == nil {
|
||||||
@@ -249,16 +249,15 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, record := range ordered {
|
for _, record := range ordered {
|
||||||
beforeNode, beforePresent := documentAtPath(decoded.final, record.path)
|
_, beforePresent := documentAtPath(decoded.final, record.path)
|
||||||
afterNode, afterPresent := documentAtPath(after, record.path)
|
afterNode, afterPresent := documentAtPath(after, record.path)
|
||||||
if !beforePresent && !afterPresent {
|
if !beforePresent && !afterPresent {
|
||||||
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if record.helperExpected != nil && (!afterPresent || !documentNodesEqual(record.helperExpected, afterNode)) {
|
if record.helperExpected != nil && (!afterPresent || !configurationValuesEqual(record.helperExpected, afterNode)) {
|
||||||
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
|
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
|
||||||
}
|
}
|
||||||
_ = beforeNode
|
|
||||||
}
|
}
|
||||||
if hasErrors(state.diagnostics) {
|
if hasErrors(state.diagnostics) {
|
||||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||||
@@ -343,17 +342,3 @@ func clonePaths(paths []Path) []Path {
|
|||||||
}
|
}
|
||||||
return cloned
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"}
|
return &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "inline field cannot declare a YAML name"}
|
||||||
}
|
}
|
||||||
inlineType := field.Type
|
inlineType := field.Type
|
||||||
|
seenPointers := make(map[reflect.Type]bool)
|
||||||
for inlineType.Kind() == reflect.Pointer {
|
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()
|
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() {
|
switch inlineType.Kind() {
|
||||||
case reflect.Struct:
|
case reflect.Struct:
|
||||||
if b.building[inlineType] {
|
if b.building[inlineType] {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -302,3 +303,40 @@ func mustPath(t *testing.T, pointer string) Path {
|
|||||||
}
|
}
|
||||||
return 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)
|
temporary_dir=$(mktemp -d)
|
||||||
trap 'rm -rf -- "$temporary_dir"' EXIT
|
trap 'rm -rf -- "$temporary_dir"' EXIT
|
||||||
git ls-files --cached --others --exclude-standard -z |
|
candidate_manifest=$temporary_dir/candidate-files
|
||||||
tar --null --files-from=- --create --file=- |
|
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"
|
tar --extract --file=- --directory="$temporary_dir"
|
||||||
(
|
(
|
||||||
cd "$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.mod "$temporary_dir/go.mod"
|
||||||
diff -u go.sum "$temporary_dir/go.sum"
|
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
|
if ((${#go_files[@]})); then
|
||||||
unformatted=$(gofmt -l -- "${go_files[@]}")
|
unformatted=$(gofmt -l -- "${go_files[@]}")
|
||||||
if [[ -n "$unformatted" ]]; then
|
if [[ -n "$unformatted" ]]; then
|
||||||
@@ -46,6 +56,9 @@ fi
|
|||||||
|
|
||||||
git diff --check HEAD --
|
git diff --check HEAD --
|
||||||
while IFS= read -r -d '' candidate_file; do
|
while IFS= read -r -d '' candidate_file; do
|
||||||
|
if [[ ! -f "$candidate_file" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
case "$candidate_file" in
|
case "$candidate_file" in
|
||||||
*.go|*.md|*.sh|go.mod|go.sum|LICENSE|.gitignore|AGENTS.md)
|
*.go|*.md|*.sh|go.mod|go.sum|LICENSE|.gitignore|AGENTS.md)
|
||||||
if LC_ALL=C grep -nE '[[:blank:]]+$' -- "$candidate_file"; then
|
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
|
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)
|
paths := make(map[string]Path)
|
||||||
finalPaths := make(map[string]*document.Node)
|
finalPaths := make(map[string]*document.Node)
|
||||||
indexDocument(final, Path{}, finalPaths)
|
indexDocument(final, Path{}, finalPaths)
|
||||||
@@ -36,6 +36,16 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
|
|||||||
}
|
}
|
||||||
|
|
||||||
secret := make(map[string]bool)
|
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 {
|
for pointer, path := range paths {
|
||||||
if root, ok := schemaSecretRoot(schema, path); ok {
|
if root, ok := schemaSecretRoot(schema, path); ok {
|
||||||
secret[root.String()] = true
|
secret[root.String()] = true
|
||||||
@@ -129,3 +139,15 @@ func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
|
|||||||
}
|
}
|
||||||
return Path{}, false
|
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
|
package configkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -139,7 +140,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
keys := value.MapKeys()
|
keys := value.MapKeys()
|
||||||
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmpString(left.String(), right.String()) })
|
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) })
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
entry := reflect.New(value.Type().Elem()).Elem()
|
entry := reflect.New(value.Type().Elem()).Elem()
|
||||||
entry.Set(value.MapIndex(key))
|
entry.Set(value.MapIndex(key))
|
||||||
@@ -167,7 +168,7 @@ func secretDiagnostic(code Code, path Path, message string, input *document.Node
|
|||||||
func changedSecretStates(root reflect.Value, descriptor *typeDescriptor, lookups map[string]secretLookupResult) []Path {
|
func changedSecretStates(root reflect.Value, descriptor *typeDescriptor, lookups map[string]secretLookupResult) []Path {
|
||||||
var changed []Path
|
var changed []Path
|
||||||
collectChangedSecretStates(root, descriptor, Path{}, lookups, &changed)
|
collectChangedSecretStates(root, descriptor, Path{}, lookups, &changed)
|
||||||
slices.SortFunc(changed, func(left, right Path) int { return cmpString(left.String(), right.String()) })
|
slices.SortFunc(changed, func(left, right Path) int { return cmp.Compare(left.String(), right.String()) })
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
keys := value.MapKeys()
|
keys := value.MapKeys()
|
||||||
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmpString(left.String(), right.String()) })
|
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) })
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed)
|
collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed)
|
||||||
}
|
}
|
||||||
@@ -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}
|
info := &SourceInfo{ID: sourceID, Label: source.label, Layer: LayerBase}
|
||||||
|
|
||||||
var (
|
var input io.Reader
|
||||||
input io.Reader
|
|
||||||
close func() error
|
|
||||||
)
|
|
||||||
switch source.kind {
|
switch source.kind {
|
||||||
case sourceFile:
|
case sourceFile:
|
||||||
absolute, err := filepath.Abs(source.path)
|
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")}
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceRead, sourceID, "source path could not be resolved")}
|
||||||
}
|
}
|
||||||
info.Path = filepath.Clean(absolute)
|
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)
|
canonical, err := filepath.EvalSymlinks(info.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = file.Close()
|
|
||||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
||||||
}
|
}
|
||||||
info.Path = canonical
|
info.Path = canonical
|
||||||
if stat.Size() > limit {
|
data, failure := readRegularFile(canonical, os.Stat, os.Open, limit)
|
||||||
_ = file.Close()
|
if failure != nil {
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "source exceeds the configured byte limit")}
|
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:
|
case sourceBytes:
|
||||||
input = bytes.NewReader(source.data)
|
input = bytes.NewReader(source.data)
|
||||||
case sourceReader:
|
case sourceReader:
|
||||||
@@ -171,16 +153,9 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
|||||||
default:
|
default:
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
|
||||||
}
|
}
|
||||||
if close != nil {
|
data, failure := readBoundedSource(input, limit)
|
||||||
defer func() { _ = close() }()
|
if failure != nil {
|
||||||
}
|
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
||||||
|
|
||||||
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")}
|
|
||||||
}
|
}
|
||||||
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
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)
|
source := File(path)
|
||||||
first, diagnostics := readSource(source, "source-1", defaultSourceLimits())
|
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)
|
t.Fatalf("first read = %#v, %#v", first, diagnostics)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, []byte("value: second\n"), 0o600); err != nil {
|
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))
|
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path))
|
||||||
return canonicalDiagnostics(state.diagnostics)
|
return canonicalDiagnostics(state.diagnostics)
|
||||||
}
|
}
|
||||||
for _, delta := range compareDocumentTrees(before, after) {
|
state.diagnostics = append(state.diagnostics, validationValueMutations(before, after)...)
|
||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(delta.path))
|
|
||||||
}
|
|
||||||
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) {
|
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) {
|
||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
|
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
|
||||||
}
|
}
|
||||||
@@ -166,3 +164,71 @@ func nearestDocumentLocation(root *document.Node, path Path) (document.Location,
|
|||||||
func locationKnown(location document.Location) bool {
|
func locationKnown(location document.Location) bool {
|
||||||
return location.SourceID != "" || location.Line != 0 || location.Column != 0
|
return location.SourceID != "" || location.Line != 0 || location.Column != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validationValueMutations combines value and sensitivity changes; resolved
|
||||||
|
// secret state is audited separately because it never enters projected trees.
|
||||||
|
func validationValueMutations(before, after *document.Node) []Diagnostic {
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
for _, delta := range compareConfigurationValues(before, after) {
|
||||||
|
diagnostics = append(diagnostics, validationMutationDiagnostic(delta.path))
|
||||||
|
}
|
||||||
|
beforeSensitivity := projectedSensitivity(before)
|
||||||
|
afterSensitivity := projectedSensitivity(after)
|
||||||
|
for pointer, path := range beforeSensitivity {
|
||||||
|
if _, exists := afterSensitivity[pointer]; !exists {
|
||||||
|
diagnostics = append(diagnostics, validationMutationDiagnostic(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pointer, path := range afterSensitivity {
|
||||||
|
if _, exists := beforeSensitivity[pointer]; !exists {
|
||||||
|
diagnostics = append(diagnostics, validationMutationDiagnostic(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user