25 Commits

Author SHA1 Message Date
b3e7b62119 Prepare ConfigKit v0.1.2 2026-09-07 13:02:22 +00:00
cd730d5c99 Prepare ConfigKit v0.1.1 2026-09-07 03:06:14 +00:00
55fd42921a Prepare ConfigKit v0.1.0 2026-09-07 01:52:38 +00:00
4bd83639a3 Retire completed roadmaps and preserve release gates 2026-09-07 01:42:57 +00:00
994a9ea0e2 Add an everyday check command and contributor test map 2026-09-07 01:40:39 +00:00
cb5b4356aa Complete ownership documentation and integration validation 2026-09-07 01:19:00 +00:00
22a03c9f4d Separate explanation replay snapshots and rendering 2026-09-07 01:14:17 +00:00
6a1c7df4be Extract explicit configuration loading phases 2026-09-07 01:13:20 +00:00
75a5829132 Clarify filesystem directive and override ownership 2026-09-07 01:10:21 +00:00
1cc645f30f Separate projection cloning and value comparison 2026-09-07 01:08:43 +00:00
633c5cd519 Share semantic document traversal helpers 2026-09-07 01:07:02 +00:00
fc2b6c81a1 Share bounded regular file read mechanics 2026-09-07 01:06:35 +00:00
a04d32c389 Centralize parser diagnostic translation 2026-09-07 01:04:08 +00:00
49e04442da Remove unused overlay value snapshots 2026-09-07 01:02:27 +00:00
874adaaa18 Document results of a code maintainability audit for an upcoming implementation sprint 2026-09-06 19:58:18 -05:00
9b147bfd96 Reject validator changes to secret classification 2026-09-06 19:20:04 -05:00
fb2e2c5386 Run release checks with the stock macOS Bash 2026-09-06 19:16:00 -05:00
537d26e359 Check canonical source paths correctly on macOS 2026-09-06 19:13:16 -05:00
3e70ad1285 Reject unsupported inline pointer schemas safely 2026-09-06 19:12:19 -05:00
be2ba9a20b Validate metadata queries against semantic path shapes 2026-09-06 19:11:30 -05:00
edd82e15b2 Replay added configuration paths in explanations 2026-09-06 19:08:29 -05:00
831c83a0b5 Explain defaults restored during final decoding 2026-09-06 19:07:03 -05:00
f7ca150a7f Detach callback values before publishing configuration 2026-09-06 19:05:28 -05:00
9c94f2b70a Reject nonregular configuration files before opening them 2026-09-06 19:04:04 -05:00
e0f9541f5f Preserve secret tags in dynamic configuration values 2026-09-06 19:03:00 -05:00
72 changed files with 3347 additions and 3883 deletions

View File

@@ -33,9 +33,7 @@ func ReadConfig(path string) (Config, error) {
The [configuration contract](docs/config.md) describes implemented behavior. The
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
The [initial target record](docs/roadmap/initial.md) links implemented feature owners
and preserves deliberately deferred scope. The [implementation plan](docs/roadmap/implementation.md)
records completion evidence until release-candidate preparation retires it. See the
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the
[development guide](docs/development.md) for repository orientation and the
[release procedure](docs/release.md) for maintainer-only candidate and publication
steps.

View 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
View 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
}
}

View File

@@ -15,6 +15,17 @@ type composedDocument struct {
inputNodes int
}
// cloneComposedDocument detaches mutable composition and provenance state;
// history application trees are immutable and retain their existing sharing.
func cloneComposedDocument(base *composedDocument) *composedDocument {
return &composedDocument{
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
applications: cloneHistoryApplications(base.applications),
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
}
}
type compositionClaim struct {
path Path
kind document.Kind

View File

@@ -56,7 +56,7 @@ values: [one, two]
if len(reversedDiagnostics) != 0 {
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
}
if deltas := compareDocumentTrees(composed.root, reversed.root); len(deltas) != 0 {
if deltas := compareConfigurationValues(composed.root, reversed.root); len(deltas) != 0 {
t.Fatalf("input order changed effective values: %#v", deltas)
}

View File

@@ -40,7 +40,7 @@ func FuzzCompositionAndOverlay(f *testing.F) {
if len(reversedDiagnostics) != 0 {
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
}
if differences := compareDocumentTrees(composed.root, reversed.root); len(differences) != 0 {
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
t.Fatalf("composition order changed effective values: %#v", differences)
}
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})

View File

@@ -237,3 +237,12 @@ func escapeControls(value string) string {
}
return b.String()
}
func hasErrors(diagnostics []Diagnostic) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Severity == SeverityError {
return true
}
}
return false
}

229
directives.go Normal file
View 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
}

View File

@@ -34,7 +34,10 @@ root `T` must satisfy the application schema contract below. With no options it
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
runs exactly once after successful source parsing, schema construction, optional
schema-version checking, and selected-profile loading. Its result is detached before
input is applied.
input is applied. Resolver-provided mutable values are detached before secret lookup
and validation, and a final detached copy prevents equal-valued aliases introduced
by validators from escaping into the returned configuration. The publication copy
preserves already validated secret state without rereading the environment.
The current lifecycle is:
@@ -58,7 +61,7 @@ semantic validator, explicit path and glob imports, one selected profile, and
filesystem context. It also accepts explicit CLI override records, static secret
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
and filesystem context as invalid combinations but accepts overrides. Input
containing a reserved `_configkit` mapping key is rejected while imports are
containing a reserved `configkit` mapping key is rejected while imports are
disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation
@@ -84,7 +87,10 @@ successful result. Callback panics remain programming failures.
### Layered metadata
`Result.Metadata` describes every final canonical path. Exact surviving input paths
`Result.Metadata` describes every final canonical path. Queries retain explicit
key/index distinctions using actual effective and historical container shapes;
only parsed numeric tokens are resolved contextually. A descendant that never
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
are explicit and have a base or profile origin; omitted paths have a default origin.
An explicit parent mapping does not make omitted children explicit. Null state and
available source coordinates are retained. `History` records the Go-zero or application
@@ -93,6 +99,13 @@ equal-valued input. Profile replacement removes displaced descendants from effec
metadata while retaining their base definition and the parent replacement operation
in history. All metadata queries and returned nested records are detached.
`Metadata.HistoricalPaths` enumerates observed paths, including removed paths,
without reloading input or resolving unselected credentials. Consumers can use
it with `History` to validate ownership of declarations hidden by an overlay;
domain ownership rules remain application responsibilities. The method's Go
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
details.
History is chronological: a path can have a synthetic default record, its exact base
definition, a profile replacement, and a recorded resolution operation. A descendant
query uses the ancestor's path for a whole-container or whole-sequence replacement;
@@ -113,7 +126,9 @@ selected profile, then synthetic CLI records in supplied order.
### Secret classification and effective rendering
Secret classification combines `configkit:"secret"` field tags with the optional
pure `Definition.Secret(Path) bool` predicate. A tagged or predicate-classified
pure `Definition.Secret(Path) bool` predicate. Tags on concrete structs inside
supported dynamic values are retained in typed projections, including superseded
defaults and pre-resolution values. A tagged or predicate-classified
container makes all descendants sensitive. ConfigKit evaluates final and historical
paths, so replacing or deleting a definition does not erase its classification. The
predicate can add sensitivity but cannot declassify a tagged path.
@@ -157,11 +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
application-default or Go-zero baseline, then includes only relevant base sources in
traversal order, the selected profile, supplied CLI records, and audited net
resolution operations. Explicit equal writes remain visible.
resolution operations. Overlapping parent/child resolution records are rendered
as one net subtree event with combined reasons and dependencies, rather than
pretending that the recorded assignments ran in lexical order. Explicit equal
writes remain visible. When input replay
differs from the final typed input, a `ChangeDecoding` event records the transition
before domain resolution. Its origin retains the decoded path's source/default
attribution; its reason identifies static-default retention and canonical decoding.
This built-in inspection operation is not accepted by `Resolution.Record`.
Base, profile, and CLI history preserves the policy-validated input representation,
including a superseded value that could not have decoded into the final Go field.
Baseline, effective, and resolution values use canonical typed projection. Mapping
Baseline, effective, and resolution values use canonical typed projection. A
decoding event bridges replayed input and canonical typed values, so intermediate
null/scalar replacements cannot incorrectly imply permanent default removal. Mapping
queries reconstruct each source or override contribution, while sequences remain
atomic: a descendant history identifies its presence before and after the containing
sequence replacement without implying element-wise merging. Removed paths remain
@@ -226,7 +250,9 @@ public-name representation, JSON marshals only the name, and projection, metadat
histories, diagnostics, effective output,
and resolution snapshots never retain the credential. Validation remains read-only:
in addition to ordinary projected-value comparison, ConfigKit checks the private
resolved state against this load's lookup result. Applications must keep their
resolved state against this load's lookup result. Validator mutation checks also
reject changes to projected field sensitivity, even when a replacement dynamic
concrete type preserves the same textual values. Applications must keep their
validation messages value-free and must not include `Value()` in diagnostics.
For example, a typed pipeline field is declared as:
@@ -400,7 +426,7 @@ origin data; history retains the displaced definitions and identifies the parent
replacement. Surviving profile paths have profile origins, while recursively shared
mapping containers retain their contributing locations.
A profile may not contain `_configkit` anywhere, contribute the configured schema
A profile may not contain `configkit` anywhere, contribute the configured schema
version, or replace an ancestor of that version with a scalar or null. These rules
apply even if the supplied version text would be unchanged. Profiles cannot mask a
base parse, composition, or version failure because those checks run first.
@@ -433,7 +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
may create missing mapping ancestors but cannot traverse an existing scalar, null,
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
Neither paths nor values may create `_configkit`, and no record may overlap the
Neither paths nor values may create `configkit`, and no record may overlap the
configured schema-version path. Unknown fields and effective type mismatches remain
ordinary strict-decoder errors.
@@ -456,12 +482,18 @@ zero.
## Explicit imports
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
`_configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
application decoding. Each sequence item is a mapping with exactly one nonempty
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
fields are `import.invalid`. A `_configkit` key below the root is invalid. With
fields are `import.invalid`. A `configkit` key below the root is invalid. With
imports disabled, any occurrence remains `import.disabled`.
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
There is no legacy alias: `_configkit` is ordinary application data, subject to
normal schema validation, and does not trigger imports. When upgrading, rename
directive keys in root files and imported fragments, and rename any application
fields previously named `configkit` to avoid the newly reserved key.
Path declarations use slash-separated relative paths. Absolute paths and network
URLs are invalid. Each path resolves from its declaring file's canonical directory,
and files are traversed depth-first in declaration order: a file contributes its own
@@ -488,7 +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
provide both `BaseDir` and `Root` as absolute existing directories; their diagnostic
labels, including URI-looking labels, never imply filesystem paths. The base and
selected file must be inside the canonical root. ConfigKit canonicalizes symlinks,
selected file must be inside the canonical root. Nonregular targets are rejected before opening to avoid blocking on FIFOs;
post-open regular-file checks remain in place under the stable-input assumption.
ConfigKit canonicalizes symlinks,
checks containment with relative paths, and opens the canonical root-relative target
through a library-owned root handle. Contained relative or absolute symlink targets
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
@@ -570,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
or pointer-to-struct, or designates at most one string-keyed inline map for otherwise
unmatched names. Ambiguous names, named inline fields, multiple inline maps, and
unsupported or malformed tag options are schema errors. Embedded fields are ordinary
unsupported or malformed tag options are schema errors. Inline pointers must
terminate in a struct; pointer-to-map and recursive pointer-only inline declarations
are rejected before the defaults factory runs. Embedded fields are ordinary
named fields unless explicitly inlined. `configkit:"secret"` is the only current
ConfigKit field tag and marks its bound field or container for secret classification
and redacted inspection.

View File

@@ -1,21 +1,16 @@
# Development
This is the contributor entry point for ConfigKit. Read the task-specific owners
below before making changes. Its public
loader currently covers strict selected sources, explicit path/glob import graphs,
composition, selected profile overlays, typed defaults, schema-version checks,
explicit CLI overrides, recorded application resolution, semantic validation,
layered metadata, secret
classification, typed environment references, explicit secrets bootstrap, and
redacted effective rendering and historical value explanation. The
[component overview](internal/overview.md) owns the implementation inventory.
Start here when contributing to ConfigKit. Read the task-specific owners below
before making changes. The [component overview](internal/overview.md) maps
implementation responsibilities; the [configuration contract](config.md) owns
public behavior.
## Initial orientation
1. Inspect the working tree and preserve unrelated changes.
2. Read the architecture policy for code or design work.
3. Read the policy, contract, and internal documents applicable to the task.
4. Inspect relevant implementation and tests where they exist before changing them.
4. Inspect the relevant implementation and behavioral tests before changing them.
## Task-specific reading guide
@@ -23,23 +18,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) |
| Documentation changes | [Documentation policy](policy/documentation.md) and the owning document |
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [initial target record](roadmap/initial.md), and relevant ADRs listed below |
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and owning contracts/tests as they are introduced |
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [deferred scope](roadmap/deferred.md), and relevant ADRs listed below |
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and the owning contract and tests from the map below |
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
| Release preparation | [Release policy](policy/release.md), [release procedure](release.md), and [release-readiness evidence](roadmap/release-readiness.md) |
| Release preparation | [Release policy](policy/release.md) and [release procedure](release.md) |
Accepted decisions:
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
For cross-cutting changes, follow every applicable row. The
[implementation plan](roadmap/implementation.md) owns the ordered stages and their
acceptance checks and recorded implementation evidence.
For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current
contracts and the component overview for ongoing work. For example:
```sh
git show cb5b435:docs/roadmap/remediation.md
```
Release-candidate, publication, and verification work follows the operational
[release procedure](release.md).
## Baseline validation
@@ -51,27 +52,51 @@ environment:
go mod download
```
Run the library baseline from the repository root:
Run the everyday check with Bash 3.2 or newer and Git:
```sh
go test ./...
go vet ./...
go build ./...
git diff --check
./scripts/check.sh
```
The default tests must remain deterministic and runnable without network access or
credentials after dependency acquisition. Also inspect new untracked files, verify
changed local links and heading fragments, and review paths, examples, ownership,
and current-versus-planned claims. The commands above do not check links or
untracked content. Do not use an application binary build as the library baseline.
Add executable-example and other checks when those artifacts are introduced; do
not document nonexistent tools as operational.
[The script](../scripts/check.sh) runs from the repository root regardless of the
invocation directory. It checks Go formatting without rewriting files, checks
staged/unstaged and untracked whitespace, then runs `go test ./...`,
`go vet ./...`, and `go build ./...`, stopping on the first failure. It uses the
installed Go toolchain by default, honors an explicit `GOTOOLCHAIN`, and disables
dependency downloads. Acquire dependencies and any selected toolchain beforehand.
Format reported Go files with `gofmt -w <files>`.
Once code exists, use focused checks while iterating and validation proportionate
to the change under the testing policy. Documentation-only work does not require
Go checks unless it changes behavior-bearing examples or commands they validate.
Release validation is a separate, stricter gate governed by release policy.
The default suite includes executable examples, maintained consumer fixtures,
local documentation-link checks, and the dependency import-boundary check.
It must stay deterministic and runnable without network access or credentials.
Still inspect new/untracked content and review paths, examples, ownership, and
current-versus-planned claims; automated checks do not replace content review.
Use focused checks while iterating, with validation proportionate to the change
under the testing policy. Documentation-only work normally needs the local-link
check below and whitespace review; behavior-bearing examples and commands also
need execution. Use `go test -race ./...` for ownership or concurrency changes.
The separate [release checker](release.md#4-validate-the-source-candidate) adds
two-toolchain, module-hygiene, race, and cross-build gates.
## Task-to-test map
Commands run from the repository root after dependency acquisition. These are
starting points for focused iteration; run the everyday check before handing off
code changes. Use the component overview to find the corresponding implementation.
| Task | Behavioral test owners | Focused command |
| --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../parse_test.go) | `go test ./internal/yaml` and `go test . -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Sources](../source_test.go), [FIFO](../source_fifo_test.go), [imports](../imports_test.go), [globs](../glob_test.go) | `go test . -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| Composition, profiles, CLI precedence | [Composition](../compose_test.go), [overlay](../overlay_test.go), [profiles](../profile_test.go), [overrides](../override_test.go) | `go test . -run 'Compose\|Overlay\|Profile\|Override'` |
| Schema, defaults, projection, cloning | [Schema](../schema_test.go), [decoding](../decode_test.go), [projection](../projection_test.go) | `go test . -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../load_test.go), [resolution](../resolution_test.go), [validation](../validation_test.go), [ownership](../callback_ownership_test.go), [independence](../independence_test.go) | `go test . -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Paths, metadata, provenance | [Paths](../path_test.go), [metadata](../metadata_test.go), [provenance](../provenance_test.go) | `go test . -run 'Path\|Metadata\|Provenance\|Queries'` |
| Secrets, bootstrap, redaction, explanation | [References](../secret_reference_test.go), [bootstrap](../secrets_test.go), [rendering](../render_test.go), [explanation](../explain_test.go) | `go test . -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go) | `go test . -run '^(TestLocalDocumentationLinks\|TestDependencyImportBoundary)$'` |
| Check-command behavior | [Shell regression](../release_check_test.go) | `go test . -run '^TestCheckScripts'` |
## Fuzz validation

View File

@@ -115,8 +115,9 @@ Assigned to explicit import traversal and glob handling:
Canonical paths—not `os.SameFile`—define identity, so hard links remain distinct.
Path-import implementation record (2026-09-06): `splitImportDirectives`,
`parseImportDeclaration`, `importFilesystem`, `importTraversal.visitPath`, and
`loadImportedDocuments` in `imports.go` adapt the assigned envelope, loading, source,
`parseImportDeclaration` (now in `directives.go`), `importFilesystem` (now in
`filesystem.go`), and `importTraversal.visitPath`/`loadImportedDocuments` in
`imports.go` adapt the assigned envelope, loading, source,
and failure mechanics. The corresponding ConfigKit tests adapt the assigned
declaration-order, disjoint composition, keyed-entry, all-source conflict, unsafe
target, alias, imported parse/schema, and relative-resolution cases. ConfigKit adds

View File

@@ -1,32 +1,43 @@
# Internal Component Overview
ConfigKit currently contains the root `configkit` package, which establishes the
module and package documentation boundary. Its implemented public primitives are:
ConfigKit exposes the root `configkit` package and keeps its document and YAML
representations private. The implemented components and their owners are:
- immutable semantic `Path` construction and strict JSON Pointer parsing;
- detached provenance and diagnostic record types with empty `Metadata` queries;
- detached effective presence/origin and chronological history queries in
`metadata.go`, with provenance and diagnostic record types;
- canonical, control-safe diagnostic rendering and structured `LoadError` access;
- opaque file, byte, reader, and no-input source construction and bounded reads;
- a parser-independent ordered tree in `internal/document`;
- opaque file, byte, reader, and no-input source construction in `source.go`,
with shared regular-file checks and bounded read mechanics in `source_file.go`;
- a parser-independent ordered tree in `internal/document`, with root-private
semantic lookup, indexing, and value traversal in `document_walk.go`;
- the sole upstream YAML integration in `internal/yaml`, which translates validated
input into that owned tree and exposes no parser types outside its boundary;
`parse.go` owns shared translation into detached, ordered public diagnostics;
- a private strict composer in `compose.go` that joins only disjoint document-tree
definitions, retains ordered claims and composite mapping origins, reports every
conflicting location, and never aliases an input tree;
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
with displacement records in `overlay.go`;
- detached parser-neutral CLI override validation and synthetic-source application
in `override.go`, sharing overlay, source, byte, node, and depth policies;
with displacement records in `overlay.go`; overlay records retain origins,
while value explanation uses separate history applications;
- CLI override validation/preparation in `override.go`, parser-neutral values in
`override_value.go`, and synthetic-source application in `override_apply.go`,
sharing overlay, source, byte, node, and depth policies;
- a per-operation private reflection descriptor graph in `schema.go` for supported
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
- descriptor-driven canonical projection, detached typed cloning, cycle/limit
enforcement, and structural comparison in `projection.go`;
- descriptor-driven canonical projection in `projection.go`, detached typed
cloning with explicit secret-state modes in `clone.go`, shared reflection and
cycle/limit state in `value_operation.go`, and configuration-value comparison
in `document_compare.go`; validation separately audits sensitivity and secrets;
- strict schema-driven decoding over detached defaults in `decode.go`, including
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
baseline/final trees, and initial default/input origins;
- public lifecycle coordination in `load.go`, bounded canonical import DFS in
`imports.go`, pattern-directed glob expansion in `glob.go`, version-report contexts
in `validation.go`, and detached layered metadata/history assembly in
- public lifecycle coordination in `load.go`, option selection in `options.go`,
input assembly in `load_input.go`, typed phases/publication in `load_result.go`,
bounded canonical import DFS in `imports.go`, filesystem context/containment in
`filesystem.go`, directive parsing/restrictions in `directives.go`,
pattern-directed glob expansion in `glob.go`, schema-version checking and report
contexts in `validation.go`, and layered metadata/history assembly in
`load_metadata.go`;
- recorded application defaulting and normalization in `resolution.go`, including
explicitness queries, helper verification, structural mutation audit, coalesced
@@ -39,15 +50,25 @@ module and package documentation boundary. Its implemented public primitives are
- explicit bounded startup environment bootstrap and safe partial reporting in
`secrets.go`;
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`;
- frozen-snapshot redaction and canonical effective output in `render.go`;
- bounded immutable input applications, safe historical replay, detached explanation
records, and deterministic human rendering in `explain.go`, with the
only upstream dumper integration isolated in `internal/yaml/write.go`;
- canonical effective output in `render.go`, using frozen-snapshot redaction
shared with explanation in `snapshot.go`;
- detached public explanation records and coordination in `explain.go`, immutable
input applications/replay and retained-node budgeting in `history.go`, shared
safe snapshot projection in `snapshot.go`, and deterministic human output in
`explain_render.go`; the only upstream dumper integration remains isolated in
`internal/yaml/write.go`;
- a separate maintained `examples/consumers` package with four sanitized consumer
shapes, fixtures, domain validators, and runtime-boundary functions; and
- a fail-fast source-candidate checker in `scripts/release-check.sh`, with local
- an everyday single-toolchain check in `scripts/check.sh` and a stricter
source-candidate checker in `scripts/release-check.sh`, with local
documentation-link and public import-boundary gates owned by root-package tests.
Dependency direction remains inward: the public lifecycle coordinates private
source, document, schema, and inspection helpers. Semantic paths stay in the root
package; `internal/document` owns only representation and cloning and never imports
the root package. Only `internal/yaml` imports the upstream YAML package. Maintained
consumer examples use the public package and standard library.
The [configuration contract](../config.md) owns implemented input, schema, decoding,
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
recorded resolution, typed secret references/bootstrap, semantic validation,
@@ -62,7 +83,7 @@ implementation:
- [Extraction inventory](extraction.md)
The [architecture policy](../policy/architecture.md) owns binding boundaries and
invariants. The [initial target record](../roadmap/initial.md) links current feature
owners and preserves deferred scope; it is not an implementation inventory. See the
invariants. The [deferred scope](../roadmap/deferred.md) records deliberate
exclusions. See the
[development guide](../development.md) for contributor orientation and validation
and the [release procedure](../release.md) for source-candidate gates.

View File

@@ -10,7 +10,7 @@ ConfigKit is an opinionated Go library for consistent YAML configuration loading
and resolution across consuming applications. This policy governs contributions.
The [component overview](../internal/overview.md) owns implementation inventory, the
[configuration contract](../config.md) owns implemented behavior, and the
[initial target record](../roadmap/initial.md) preserves deferred scope.
[deferred scope](../roadmap/deferred.md) preserves deferred scope.
## Success criteria
@@ -136,7 +136,7 @@ for safe messages supplied by their hooks.
Do not introduce automatic directory discovery, network providers, arbitrary
merge strategies, additional configuration languages, or application runtime
management without concrete consumer evidence and an explicit design decision.
The [initial target record](../roadmap/initial.md#explicit-non-goals-and-deferred-scope)
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope)
owns the deferred-feature inventory. Significant boundary
changes require an ADR and corresponding policy/contract updates; implementation
convenience alone is not a reason to broaden the public API.

View File

@@ -28,8 +28,8 @@ semantics, defaults, paths, diagnostics, or compatibility guarantees.
implementation; execution status belongs in roadmaps.
- Roadmaps own unimplemented targets, deferred work, and execution status.
The initial target and implementation plan have separate owners. Do not put
staged steps or progress in `docs/roadmap/initial.md`. `docs/roadmap/implementation.md` owns those details.
Future implementation plans own their task order, acceptance gates, and progress;
`docs/roadmap/deferred.md` owns deliberate exclusions, not execution instructions.
As behavior lands, move its normative definition into the current contract and
replace the roadmap definition with a link. Retire completed roadmap material
as part of release preparation; ADRs preserve significant decision history.
@@ -56,7 +56,7 @@ handling, not secret values.
| Topic | Owner | Boundary |
| --- | --- | --- |
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example once available |
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example |
| Contributor entry point | `docs/development.md` | Reading guide and actual baseline validation commands |
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
@@ -64,14 +64,13 @@ handling, not secret values.
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
| Release procedure | `docs/release.md` | Exact maintainer commands, prerequisites, supported targets, and verification |
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle API behavior |
| YAML and resolution semantics | `docs/config.md`, as behavior lands | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
| YAML and resolution semantics | `docs/config.md` | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
| Consumer walkthroughs | `docs/consumers/`, when useful | Task-oriented usage and consumer responsibilities, not duplicate API reference |
| Component inventory | `docs/internal/overview.md` | Implemented components and links to their owners |
| Component mechanics | Other `docs/internal/` files, when needed | Detailed implementation behavior and change recipes |
| Architectural decisions | `docs/adr/` | Context, decisions, alternatives, consequences, and supersession history |
| Initial target record | `docs/roadmap/initial.md` | Implemented-feature owner links and deferred feature scope until roadmap retirement |
| Implementation plan | `docs/roadmap/implementation.md` | Stages, dependencies, acceptance gates, and progress |
| Release readiness evidence | `docs/roadmap/release-readiness.md` | Closed preparation gaps and outstanding candidate/publication evidence until roadmap retirement |
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and the conditions for reconsidering them |
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, task order, acceptance gates, and progress; completed plans retire to Git history |
| Historical release notes | `docs/releases/` | Version-specific historical summaries required by release policy |
| Complete examples | `examples/` and executable Go examples | Maintained artifacts; prose links instead of duplicating full examples |

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 0.1.0
**Last Updated:** 2026-09-06
**Last Updated:** 2026-09-07
## Purpose
@@ -220,23 +220,12 @@ restating or redefining its requirements.
## Policy Exceptions
### Temporary bootstrap deferral
For v0.1.1 only, the maintainer approved renaming the reserved `_configkit` key to
`configkit` as an error-correcting point release because there are no current
consumers. This intentionally changes the v0.1.0 configuration contract without a
minor-version increment. The release note must explain the renamed directive,
the lack of a legacy alias, and the newly reserved application key. This exception
does not apply to subsequent releases or waive any validation or publication gate.
The release procedure and automated source-candidate checker now exist, but no exact
release candidate, version-matched release note, native macOS execution evidence, or
post-publication module retrieval has been validated. Initial implementation may be
completed without fabricating those later facts. This exception does not authorize
publication or waive any candidate, platform, remote, or consumer-verification gate.
Compensating controls: the [release procedure](../release.md) keeps every unvalidated
step as an explicit maintainer gate, and the
[release-readiness evidence](../roadmap/release-readiness.md) records what is closed
and outstanding. No release may be published until an exact committed candidate has
the required release note, passes all applicable automated and native-platform gates,
is verified against `origin/main`, and completes guarded tag publication and clean
consumer retrieval. The maintainer preparing the first release owns closure. Remove
this temporary exception when that evidence is recorded and obsolete roadmap files
are retired. Any other exception requires a documented repository constraint, rationale,
compensating controls, scope, and closure condition here; none is currently
adopted. Do not silently weaken the base integrity, validation, documentation,
verification, or immutability guarantees.
Candidate selection, publication, and post-publication verification remain
mandatory steps in the [release procedure](../release.md).

View File

@@ -22,8 +22,9 @@ and macOS; cross-compilation is not native execution evidence.
## 1. Prepare access and tools
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash,
and two supported Go toolchains available. Configure private-host Git and Go module
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash 3.2 or newer,
and two supported Go toolchains available. The checker supports the stock macOS
Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module
access through the normal credential manager before starting. Keep tokens, passwords,
SSH material, and environment dumps outside the repository and command transcript.
Verify access without displaying credentials:

72
docs/releases/v0.1.0.md Normal file
View 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
View 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
View 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
View 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

View File

@@ -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.

View File

@@ -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
View 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
View 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
}

View File

@@ -1,4 +1,4 @@
_configkit:
configkit:
imports:
- path: artifacts.yml
poll_interval: 1m

View File

@@ -1,23 +1,6 @@
package configkit
import (
"bytes"
"errors"
"io"
"slices"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
const historyNodeLimit = 4_000_000
var (
errExplanationUnavailable = errors.New("configkit: configuration explanation is unavailable")
errExplanationUnknownPath = errors.New("configkit: configuration explanation path is unknown")
)
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// ValueSnapshot is a detached, deterministic YAML representation of one value.
// Present distinguishes absence or deletion from an explicit null value.
@@ -51,42 +34,10 @@ type Explanation struct {
SecretReference *SecretReferenceInfo
}
type historyApplication struct {
layer Layer
path Path
value *document.Node
origin Origin
}
func cloneHistoryApplications(values []historyApplication) []historyApplication {
if values == nil {
return nil
}
cloned := make([]historyApplication, len(values))
for index, value := range values {
cloned[index] = historyApplication{
layer: value.layer, path: value.path.clone(), value: value.value,
origin: cloneOrigin(value.origin),
}
}
return cloned
}
func cloneSecretReferences(values map[string]string) map[string]string {
if values == nil {
return nil
}
cloned := make(map[string]string, len(values))
for path, name := range values {
cloned[path] = name
}
return cloned
}
// Explain returns safe effective and chronological values for a known final or
// historical path. Unknown and malformed paths return false.
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if r == nil || r.final == nil || r.baseline == nil || !path.valid() {
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
return Explanation{}, false
}
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
@@ -110,15 +61,35 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if !relevant {
continue
}
after := valueSnapshot(working, path, r.secrets)
if !before.Present && !after.Present {
continue
}
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: valueSnapshot(working, path, r.secrets),
After: after,
Origin: historyApplicationOrigin(application, operationPath),
})
}
// Intermediate input replacements do not permanently suppress defaults:
// decoding applies them to the final input tree. Show that reconciliation
// explicitly, including canonical scalar conversions, before domain changes.
replayed, _ := documentAtPath(working, path)
decoded, _ := documentAtPath(r.input, path)
if !configurationValuesEqual(replayed, decoded) {
origin := cloneOrigin(r.inputOrigins[path.String()])
origin.Operation = ChangeDecoding
explanation.History = append(explanation.History, ValueChange{
Before: valueSnapshot(working, path, r.secrets),
After: valueSnapshot(r.input, path, r.secrets),
Origin: origin,
Reasons: []string{"applied retained defaults and canonical typed decoding"},
})
}
working = document.Clone(r.input)
for _, snapshot := range r.resolution {
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
relevant := pathsIntersect(snapshot.change.Path, path)
var before ValueSnapshot
if relevant {
@@ -128,9 +99,13 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if !relevant {
continue
}
after := valueSnapshot(working, path, r.secrets)
if !before.Present && !after.Present {
continue
}
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: valueSnapshot(working, path, r.secrets),
After: after,
Origin: cloneOrigin(snapshot.change.Origin),
Reasons: append([]string(nil), snapshot.change.Reasons...),
Dependencies: clonePaths(snapshot.change.Dependencies),
@@ -145,363 +120,3 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
}
return explanation, true
}
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
if !application.path.valid() || application.value == nil {
return Path{}, false
}
if application.path.String() != "" {
if pathsIntersect(application.path, query) {
return application.path.clone(), true
}
return Path{}, false
}
if len(query.segments) == 0 {
return Path{}, true
}
for length := 1; length <= len(query.segments); length++ {
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
node, present := documentAtPath(application.value, prefix)
if !present {
return Path{}, false
}
if length == len(query.segments) || node.Kind != document.Mapping {
return prefix, true
}
}
return Path{}, false
}
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
if application.origin.Layer != "" {
return cloneOrigin(application.origin)
}
origin := Origin{Layer: application.layer}
relative, ok := relativeHistoryPath(application.path, operationPath)
if !ok {
return origin
}
if node, present := documentAtPath(application.value, relative); present {
origin.Locations = []Location{publicLocation(node.Location)}
}
return origin
}
func relativeHistoryPath(root, path Path) (Path, bool) {
if !pathContains(root, path) {
return Path{}, false
}
return Path{segments: clonePathSegments(path.segments[len(root.segments):], 0)}, true
}
func pathsIntersect(left, right Path) bool {
return pathContains(left, right) || pathContains(right, left)
}
func applyHistoryApplication(root **document.Node, application historyApplication) {
if application.path.String() == "" {
mergeHistoryDocument(root, application.value)
return
}
mergeHistoryAtPath(root, application.path, application.value)
}
func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
if incoming == nil {
return
}
if *target == nil || (*target).Kind != document.Mapping || incoming.Kind != document.Mapping {
*target = document.Clone(incoming)
return
}
for _, entry := range incoming.Mapping {
position := documentEntryIndex((*target).Mapping, entry.Key)
if position < 0 {
cloned := entry
cloned.Value = document.Clone(entry.Value)
(*target).Mapping = append((*target).Mapping, cloned)
continue
}
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value)
}
}
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) {
if root == nil || *root == nil || len(path.segments) == 0 {
mergeHistoryDocument(root, incoming)
return
}
current := *root
for index, segment := range path.segments {
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
return
}
position := documentEntryIndex(current.Mapping, segment.key)
if index == len(path.segments)-1 {
if position < 0 {
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
} else {
mergeHistoryDocument(&current.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
View 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')
}
}
}

View File

@@ -23,7 +23,7 @@ type explanationConcurrency struct {
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "config.yml", "_configkit:\n imports: [{path: transcription.yml}]\n")
mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n")
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
@@ -326,3 +326,87 @@ func historyValueLayers(explanation Explanation) []Layer {
}
return layers
}
func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
type cfg struct {
M map[string]int `yaml:"m"`
}
for _, base := range []string{"null", "replaced-text"} {
for _, cli := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/cli=%v", base, cli), func(t *testing.T) {
root := t.TempDir()
main := writeImportFile(t, root, "main.yml", "m: "+base+"\n")
opts := Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg { return cfg{M: map[string]int{"a": 1}} }}}
if cli {
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
} else {
writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n")
opts.Profile = "p"
}
result, err := Load(File(main), opts)
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("m"), Keys("m", "a")} {
explanation, ok := result.Explain(path)
if !ok {
t.Fatal("missing explanation")
}
last := explanation.History[len(explanation.History)-1]
if last.After != explanation.Effective || last.Origin.Operation != ChangeDecoding {
t.Fatalf("history does not reconcile final value: %#v", explanation)
}
info, _ := result.Metadata().Lookup(path)
if last.Origin.Layer != info.Origin.Layer {
t.Fatal("decoding lost effective origin")
}
}
})
}
}
}
func 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
View 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))
}

View File

@@ -10,13 +10,13 @@ import (
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: fragments/*.yml
- path: after.yml
items: {root: root}
`)
aPath := writeImportFile(t, directory, "fragments/a.yml", `_configkit:
aPath := writeImportFile(t, directory, "fragments/a.yml", `configkit:
imports: [{path: nested/child.yml}]
items: {alpha: alpha}
`)
@@ -56,7 +56,7 @@ items: {alpha: alpha}
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: 'conf.d/literal\*.yml'
- glob: 'conf.d/item-[ab].yml'
@@ -126,7 +126,7 @@ func TestLoadGlobImportsPreserveIdentityAndFileRules(t *testing.T) {
t.Run("overlapping declarations", func(t *testing.T) {
directory := t.TempDir()
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: conf/*.yml
- glob: conf/value.*
@@ -269,5 +269,5 @@ func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) strin
if optional {
optionalLine = "\n optional: true"
}
return writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
}

276
history.go Normal file
View 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(&current.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
}

View File

@@ -3,7 +3,6 @@ package configkit
import (
"bytes"
"errors"
"io"
"io/fs"
"os"
"path"
@@ -36,13 +35,6 @@ func defaultImportLimits() importLimits {
}
}
type importDeclaration struct {
path string
glob string
optional bool
location Location
}
type importRoute struct {
canonical string
locations []Location
@@ -121,120 +113,6 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
return composed, cloneSourceInfos(state.sources), diagnostics
}
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil {
return diagnostic
}
root, err := os.OpenRoot(rootPath)
if err != nil {
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
value.Primary = nil
return &value
}
if err := root.Close(); err != nil {
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
return &value
}
return nil
}
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
switch source.kind {
case sourceNone:
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
case sourceFile:
if context.BaseDir != "" {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")}
}
case sourceBytes, sourceReader:
if context.BaseDir == "" || context.Root == "" {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")}
}
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")}
}
}
return nil
}
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
if main.source == nil || main.document == nil {
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
return "", "", "", &value
}
if source.kind == sourceFile {
if context != nil && context.BaseDir != "" {
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
return "", "", "", &value
}
canonical, err := canonicalExistingPath(source.path)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
return "", "", "", &value
}
mainPath = canonical
basePath = filepath.Dir(canonical)
rootPath = basePath
if context != nil && context.Root != "" {
rootPath, err = canonicalExistingPath(context.Root)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
return "", "", "", &value
}
}
} else {
if context == nil || context.BaseDir == "" || context.Root == "" {
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
return "", "", "", &value
}
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
return "", "", "", &value
}
var err error
basePath, err = canonicalExistingPath(context.BaseDir)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
return "", "", "", &value
}
rootPath, err = canonicalExistingPath(context.Root)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
return "", "", "", &value
}
}
if !containedPath(rootPath, basePath) || (mainPath != "" && !containedPath(rootPath, mainPath)) {
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
return "", "", "", &value
}
for _, candidate := range []string{rootPath, basePath} {
info, err := os.Stat(candidate)
if err != nil || !info.IsDir() {
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
return "", "", "", &value
}
}
return rootPath, basePath, mainPath, nil
}
func canonicalExistingPath(name string) (string, error) {
absolute, err := filepath.Abs(name)
if err != nil {
return "", err
}
return filepath.EvalSymlinks(filepath.Clean(absolute))
}
func containedPath(root, target string) bool {
relative, err := filepath.Rel(root, target)
if err != nil || filepath.IsAbs(relative) {
return false
}
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}
func (s *importTraversal) checkInitialLimits() *Diagnostic {
if s.limits.sources < 1 {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
@@ -345,7 +223,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
})
if len(issues) != 0 {
return importParseDiagnostics(sourceID, issues)
return parserDiagnostics(sourceID, issues)
}
nodeCount := countInputNodes(root)
if s.usedNodes+nodeCount > s.limits.nodes {
@@ -382,35 +260,12 @@ func (s *importTraversal) readImport(relative, sourceID string, optional bool) (
if limit < 0 {
limit = 0
}
file, err := s.root.Open(relative)
if err != nil {
if optional && errors.Is(err, fs.ErrNotExist) {
data, failure := readRegularFile(relative, s.root.Stat, s.root.Open, limit)
if failure != nil {
if optional && failure.missingTarget {
return nil, true, nil
}
value := classifySourceError(err, sourceID)
return nil, false, &value
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
value := classifySourceError(err, sourceID)
return nil, false, &value
}
if !info.Mode().IsRegular() {
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
return nil, false, &value
}
if info.Size() > limit {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
return nil, false, &value
}
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil {
value := classifySourceError(err, sourceID)
return nil, false, &value
}
if int64(len(data)) > limit {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
return nil, false, &value
}
s.usedBytes += int64(len(data))
@@ -428,184 +283,6 @@ func (s *importTraversal) currentRoute(location Location) importRoute {
return importRoute{locations: locations}
}
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
body := document.Clone(root)
var declarations []importDeclaration
directiveIndex := -1
for index, entry := range body.Mapping {
if entry.Key != "_configkit" {
continue
}
directiveIndex = index
parsed, diagnostics := parseImportDirective(entry.Value, Keys("_configkit"))
if len(diagnostics) != 0 {
return nil, nil, diagnostics
}
declarations = parsed
break
}
if directiveIndex >= 0 {
body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...)
}
if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 {
return nil, nil, diagnostics
}
return body, declarations, nil
}
func parseImportDirective(node *document.Node, directivePath Path) ([]importDeclaration, []Diagnostic) {
if node.Kind != document.Mapping {
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
}
var declarations []importDeclaration
for _, entry := range node.Mapping {
fieldPath := directivePath.Key(entry.Key)
if entry.Key != "imports" {
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value)}
}
if entry.Value.Kind != document.Sequence {
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)}
}
for index, item := range entry.Value.Sequence {
declaration, diagnostics := parseImportDeclaration(item, fieldPath.Index(index))
if len(diagnostics) != 0 {
return nil, diagnostics
}
declarations = append(declarations, declaration)
}
}
return declarations, nil
}
func parseImportDeclaration(node *document.Node, declarationPath Path) (importDeclaration, []Diagnostic) {
if node.Kind != document.Mapping {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
}
declaration := importDeclaration{location: publicLocation(node.Location)}
seenTarget := false
for _, entry := range node.Mapping {
fieldPath := declarationPath.Key(entry.Key)
switch entry.Key {
case "path", "glob":
if seenTarget || entry.Value.Kind != document.String || entry.Value.Text == "" || strings.TrimSpace(entry.Value.Text) != entry.Value.Text {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration requires exactly one nonempty path or glob string", entry.Value)}
}
seenTarget = true
declaration.location = publicLocation(entry.Value.Location)
if entry.Key == "path" {
declaration.path = entry.Value.Text
} else {
declaration.glob = entry.Value.Text
}
case "optional":
if entry.Value.Kind != document.Boolean {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
}
declaration.optional = entry.Value.Text == "true"
default:
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
}
}
if !seenTarget {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path or glob", node)}
}
return declaration, nil
}
func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
childPath := current.Key(entry.Key)
if entry.Key == "_configkit" {
location := publicLocation(entry.KeyLocation)
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: childPath, Message: "configuration directives are permitted only at the document root", Primary: &location}}
}
if diagnostics := nestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
return diagnostics
}
}
} else if node.Kind == document.Sequence {
for index, child := range node.Sequence {
if diagnostics := nestedDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
return diagnostics
}
}
}
return nil
}
func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node {
if rule == nil {
return nil
}
current := root
if len(rule.Path.segments) == 0 {
return current
}
for _, segment := range rule.Path.segments {
if current.Kind != document.Mapping {
return current
}
if segment.kind == pathSegmentIndex {
return nil
}
key := segment.key
index := documentEntryIndex(current.Mapping, key)
if index < 0 {
return nil
}
current = current.Mapping[index].Value
}
return current
}
func countInputNodes(node *document.Node) int {
if node == nil {
return 0
}
count := 1
if node.Kind == document.Mapping {
count += len(node.Mapping)
for _, entry := range node.Mapping {
count += countInputNodes(entry.Value)
}
} else if node.Kind == document.Sequence {
for _, child := range node.Sequence {
count += countInputNodes(child)
}
}
return count
}
func validImportPath(value string) bool {
if value == "" || strings.TrimSpace(value) != value || path.IsAbs(value) || strings.HasPrefix(value, "//") {
return false
}
return !hasImportScheme(value)
}
func hasImportScheme(value string) bool {
colon := strings.IndexByte(value, ':')
slash := strings.IndexByte(value, '/')
if colon <= 0 || (slash >= 0 && slash < colon) {
return false
}
for index := 0; index < colon; index++ {
character := value[index]
if index == 0 {
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') {
return false
}
continue
}
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') &&
(character < '0' || character > '9') && character != '+' && character != '-' && character != '.' {
return false
}
}
return true
}
func sourceInfos(source *SourceInfo) []SourceInfo {
if source == nil {
return nil
@@ -613,29 +290,10 @@ func sourceInfos(source *SourceInfo) []SourceInfo {
return []SourceInfo{cloneSourceInfo(*source)}
}
func importParseDiagnostics(sourceID string, issues []yamladapter.Issue) []Diagnostic {
diagnostics := make([]Diagnostic, len(issues))
for index, issue := range issues {
diagnostics[index] = Diagnostic{
Code: Code(issue.Code), Severity: SeverityError, Message: issue.Message,
Primary: &Location{SourceID: sourceID, Line: issue.Line, Column: issue.Column},
}
for _, related := range issue.Related {
diagnostics[index].Related = append(diagnostics[index].Related, publicLocation(related))
}
}
return diagnostics
}
func importDiagnostic(code Code, message string, location *Location) Diagnostic {
return Diagnostic{Code: code, Severity: SeverityError, Message: message, Primary: cloneLocation(location)}
}
func nodeImportDiagnostic(code Code, path Path, message string, node *document.Node) Diagnostic {
location := publicLocation(node.Location)
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
}
func importCycleDiagnostic(target string, location Location, cycle []importRoute) Diagnostic {
paths := make([]string, 0, len(cycle)+1)
related := make([]Location, 0, len(cycle))

View File

@@ -22,16 +22,37 @@ type importTestConfig struct {
Items map[string]string `yaml:"items"`
}
func TestLoadFormerDirectiveKeyIsApplicationData(t *testing.T) {
type config struct {
Legacy struct {
Imports []struct {
Path string `yaml:"path"`
} `yaml:"imports"`
} `yaml:"_configkit"`
}
for _, imports := range []bool{false, true} {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: nonexistent.yml\n")
result, err := Load[config](File(mainPath), Options[config]{Imports: imports})
if err != nil {
t.Fatalf("Imports=%v: %v", imports, err)
}
if len(result.Config.Legacy.Imports) != 1 || result.Config.Legacy.Imports[0].Path != "nonexistent.yml" || len(result.Sources()) != 1 {
t.Fatalf("Imports=%v: former directive was not retained as application data: %#v", imports, result)
}
}
}
func TestLoadTraversesPathImportsDepthFirstAndComposesOrigins(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: first.yml
- path: second.yml
settings:
root: root
`)
firstPath := writeImportFile(t, directory, "first.yml", `_configkit:
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
imports:
- path: nested/third.yml
settings:
@@ -71,7 +92,7 @@ settings:
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
t.Run("all duplicate claimants", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: one.yml
- path: two.yml
@@ -97,7 +118,7 @@ func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: fragment.yml\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: fragment.yml\n")
writeImportFile(t, directory, "fragment.yml", test.fragment)
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
diagnostic := loadError.Diagnostics()[0]
@@ -114,15 +135,15 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
body string
code Code
}{
{name: "directive kind", body: "_configkit: []\n", code: CodeImportInvalid},
{name: "unknown directive field", body: "_configkit: {unknown: true}\n", code: CodeImportInvalid},
{name: "imports kind", body: "_configkit: {imports: {}}\n", code: CodeImportInvalid},
{name: "declaration kind", body: "_configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
{name: "missing target", body: "_configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
{name: "two targets", body: "_configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
{name: "unknown declaration field", body: "_configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
{name: "optional kind", body: "_configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
{name: "nested directive", body: "nested: {_configkit: {}}\n", code: CodeImportInvalid},
{name: "directive kind", body: "configkit: []\n", code: CodeImportInvalid},
{name: "unknown directive field", body: "configkit: {unknown: true}\n", code: CodeImportInvalid},
{name: "imports kind", body: "configkit: {imports: {}}\n", code: CodeImportInvalid},
{name: "declaration kind", body: "configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
{name: "missing target", body: "configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
{name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -136,7 +157,7 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
t.Run("optional missing", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: missing.yml
optional: true
@@ -172,7 +193,7 @@ settings: {root: retained}
if test.setup != nil {
test.setup(t, directory)
}
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: \""+test.path+"\"\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: \""+test.path+"\"\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
})
}
@@ -181,9 +202,9 @@ settings: {root: retained}
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
t.Run("cycle", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: a.yml}]\n")
writeImportFile(t, directory, "a.yml", "_configkit:\n imports: [{path: b.yml}]\n")
writeImportFile(t, directory, "b.yml", "_configkit:\n imports: [{path: main.yml}]\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: a.yml}]\n")
writeImportFile(t, directory, "a.yml", "configkit:\n imports: [{path: b.yml}]\n")
writeImportFile(t, directory, "b.yml", "configkit:\n imports: [{path: main.yml}]\n")
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Primary == nil || len(diagnostic.Related) != 2 || !strings.Contains(diagnostic.Message, "main.yml") || !strings.Contains(diagnostic.Message, "a.yml") || !strings.Contains(diagnostic.Message, "b.yml") {
@@ -193,9 +214,9 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
t.Run("diamond", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
writeImportFile(t, directory, "left.yml", "_configkit:\n imports: [{path: shared.yml}]\n")
writeImportFile(t, directory, "right.yml", "_configkit:\n imports: [{path: shared.yml}]\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
writeImportFile(t, directory, "left.yml", "configkit:\n imports: [{path: shared.yml}]\n")
writeImportFile(t, directory, "right.yml", "configkit:\n imports: [{path: shared.yml}]\n")
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
diagnostic := loadError.Diagnostics()[0]
@@ -216,7 +237,7 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
if err := os.Symlink("target.yml", filepath.Join(directory, "second-link.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
})
}
@@ -231,7 +252,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
if err := os.Symlink(target, filepath.Join(directory, "link.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: link.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: link.yml}]}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) {
t.Fatalf("symlink result = %#v, %v", result, err)
@@ -245,7 +266,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
if err := os.Symlink(target, filepath.Join(root, "escape.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeImportFile(t, root, "main.yml", "_configkit: {imports: [{path: escape.yml}]}\n")
mainPath := writeImportFile(t, root, "main.yml", "configkit: {imports: [{path: escape.yml}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
})
@@ -255,7 +276,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
if err := os.Link(one, filepath.Join(directory, "two.yml")); err != nil {
t.Skipf("hard links unavailable: %v", err)
}
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
})
@@ -264,7 +285,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
if err := os.Symlink("loop.yml", filepath.Join(directory, "loop.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: loop.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: loop.yml}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
})
}
@@ -272,7 +293,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
directory := t.TempDir()
writeImportFile(t, directory, "fragment.yml", "settings: {first: imported}\n")
input := []byte("_configkit: {imports: [{path: fragment.yml}]}\n")
input := []byte("configkit: {imports: [{path: fragment.yml}]}\n")
context := &FilesystemContext{BaseDir: directory, Root: directory}
for _, test := range []struct {
name string
@@ -295,7 +316,7 @@ func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T)
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
root := t.TempDir()
mainPath := writeImportFile(t, root, "configs/main.yml", "_configkit: {imports: [{path: ../shared.yml}]}\n")
mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\n")
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
Imports: true, Filesystem: &FilesystemContext{Root: root},
@@ -309,7 +330,7 @@ func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
t.Run("parse", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: broken.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
writeImportFile(t, directory, "broken.yml", "settings: [\n")
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
diagnostics := loadError.Diagnostics()
@@ -320,7 +341,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
t.Run("decode", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: unknown.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: unknown.yml}]}\n")
writeImportFile(t, directory, "unknown.yml", "unknown: value\n")
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
diagnostic := loadError.Diagnostics()[0]
@@ -332,7 +353,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "outside.yml", "_configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
mainPath := writeImportFile(t, directory, "outside.yml", "configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
@@ -344,13 +365,13 @@ func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
_ = file.Close()
t.Skip("filesystem does not enforce the unreadable-file fixture")
}
mainPath = writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: locked.yml, optional: true}]}\n")
mainPath = writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: locked.yml, optional: true}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
}
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
main := parseImportMain(t, mainPath)
@@ -370,7 +391,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
})
}
writeImportFile(t, directory, "one.yml", "_configkit: {imports: [{path: nested.yml}]}\n")
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
main = parseImportMain(t, mainPath)
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
@@ -381,7 +402,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: imported.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: imported.yml}]}\n")
importedPath := writeImportFile(t, directory, "imported.yml", "settings: {first: imported}\n")
main := parseImportMain(t, mainPath)
imported := parseImportMain(t, importedPath)

View File

@@ -32,11 +32,14 @@ type Entry struct {
// Node is ConfigKit's owned configuration representation. Number Text retains its
// exact validated decimal spelling; Boolean Text is "true" or "false".
type Node struct {
Kind Kind
Location Location
Text string
Mapping []Entry
Sequence []*Node
// Sensitive preserves field classification from concrete typed projections.
// It is metadata, not part of the configuration value or emitted YAML.
Sensitive bool
Kind Kind
Location Location
Text string
Mapping []Entry
Sequence []*Node
}
// Clone returns a detached copy of a node tree.

330
load.go
View File

@@ -72,6 +72,7 @@ type Result[T any] struct {
diagnostics []Diagnostic
baseline *document.Node
input *document.Node
inputOrigins map[string]Origin
final *document.Node
resolution []resolutionSnapshot
applications []historyApplication
@@ -113,332 +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) {
accumulator := diagnosticAccumulator{}
options, diagnostics := selectOptions(source, supplied)
accumulator.add(diagnostics...)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
input, diagnostics := assembleBaseInput(source, options)
accumulator.sources = input.sources
accumulator.add(diagnostics...)
if len(diagnostics) != 0 {
if parsed.source != nil {
accumulator.sources = append(accumulator.sources, *parsed.source)
}
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
inputDocument := parsed.document
var composition *composedDocument
if options.Imports {
composition, accumulator.sources, diagnostics = loadImportedDocuments(source, options.Filesystem, parsed, options.Definition.Version, defaultImportLimits())
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
inputDocument = composition.root
} else {
if parsed.source != nil {
accumulator.sources = append(accumulator.sources, *parsed.source)
}
if options.Filesystem != nil && !activeProfile(options.Profile) {
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
accumulator.add(*diagnostic)
return nil, accumulator.loadError()
}
}
}
if !options.Imports && inputDocument != nil {
directiveDiagnostics := disabledDirectiveDiagnostics(inputDocument, Path{})
if len(directiveDiagnostics) != 0 {
accumulator.add(directiveDiagnostics...)
return nil, accumulator.loadError()
}
}
schema, schemaIssue := buildSchema(reflect.TypeFor[T]())
if schemaIssue != nil {
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
return nil, accumulator.loadError()
}
versionDiagnostics := checkVersion(options.Definition.Version, inputDocument)
versionDiagnostics := checkVersion(options.Definition.Version, input.document)
accumulator.add(versionDiagnostics...)
if hasErrors(versionDiagnostics) {
return nil, accumulator.loadError()
}
if activeProfile(options.Profile) {
if composition == nil {
composition, diagnostics = composeDocuments(inputDocument)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
composition.inputBytes = parsed.bytes
composition.inputNodes = countInputNodes(parsed.document)
}
composition, accumulator.sources, diagnostics = loadProfileDocument(
source, options.Filesystem, parsed, composition, accumulator.sources,
options.Profile, options.Definition.Version, defaultImportLimits(),
)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
inputDocument = composition.root
}
if len(options.Overrides) != 0 {
if composition == nil {
if inputDocument == nil {
composition = &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}
} else {
composition, diagnostics = composeDocuments(inputDocument)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
composition.inputBytes = parsed.bytes
composition.inputNodes = countInputNodes(inputDocument)
}
}
composition, accumulator.sources, diagnostics = applyOverrides(
composition, accumulator.sources, options.Overrides, options.Definition.Version, defaultImportLimits(),
)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
inputDocument = composition.root
}
defaults := reflect.Zero(reflect.TypeFor[T]())
if options.Definition.Defaults != nil {
defaults = reflect.ValueOf(options.Definition.Defaults())
}
decoded, decodeIssues := decodeConfiguration(schema, defaults, inputDocument)
if len(decodeIssues) != 0 {
for _, issue := range decodeIssues {
accumulator.add(diagnosticFromDecodeIssue(issue))
}
input, diagnostics = applyInputLayers(source, options, input)
accumulator.sources = input.sources
accumulator.add(diagnostics...)
if len(diagnostics) != 0 {
return nil, accumulator.loadError()
}
if composition != nil {
applyCompositionOrigins(&decoded, composition.origins)
}
configuration := decoded.value.Interface().(T)
metadata := metadataFromDecoded(decoded)
if composition != nil {
applyOverlayMetadata(&metadata, composition.overlays)
decoded, diagnostics := decodeLoadInput(schema, options.Definition, input)
accumulator.add(diagnostics...)
if len(diagnostics) != 0 {
return nil, accumulator.loadError()
}
final := decoded.final
var resolutionSnapshots []resolutionSnapshot
if options.Definition.Resolve != nil {
resolved := runResolution(&configuration, options.Definition.Resolve, schema, decoded, inputDocument)
accumulator.add(resolved.diagnostics...)
if hasErrors(resolved.diagnostics) {
return nil, accumulator.loadError()
}
final = resolved.final
resolutionSnapshots = resolved.snapshots
applyResolutionMetadata(&metadata, resolved)
resolved, diagnostics := resolveLoadInput(schema, options.Definition, input, decoded)
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
applications := []historyApplication(nil)
if composition != nil {
applications = cloneHistoryApplications(composition.applications)
} else if inputDocument != nil {
applications = []historyApplication{{layer: LayerBase, value: inputDocument}}
}
if !historyNodesWithinLimit(decoded.baseline, decoded.final, final, applications, resolutionSnapshots) {
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) {
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
return nil, accumulator.loadError()
}
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, inputDocument, lookup)
// Detach resolver-borrowed values before reference mutation or validation.
configuration, ownershipIssue := detachLoadConfiguration(schema, resolved.configuration, cloneUnresolvedSecrets)
if ownershipIssue != nil {
accumulator.add(*ownershipIssue)
return nil, accumulator.loadError()
}
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, input.document, lookup)
accumulator.add(secretResolution.diagnostics...)
if hasErrors(secretResolution.diagnostics) {
return nil, accumulator.loadError()
}
if options.Definition.Validate != nil {
validationDiagnostics := runValidation(&configuration, options.Definition.Validate, schema, final, inputDocument, secretResolution.lookups)
accumulator.add(validationDiagnostics...)
if hasErrors(validationDiagnostics) {
diagnostics = runValidation(&configuration, options.Definition.Validate, schema, resolved.final, input.document, secretResolution.lookups)
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
}
secrets := classifySecretPaths(schema, options.Definition.Secret, metadata, final, applications)
return &Result[T]{
Config: configuration,
metadata: metadata,
sources: cloneSourceInfos(accumulator.sources),
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
baseline: decoded.baseline,
input: decoded.final,
final: final,
resolution: resolutionSnapshots,
applications: applications,
secrets: secrets,
secretReferences: cloneSecretReferences(secretResolution.references),
}, nil
}
func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) {
if len(supplied) > 1 {
return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}
}
var options Options[T]
if len(supplied) == 1 {
options = supplied[0]
}
var diagnostics []Diagnostic
definition := options.Definition
if !validProfileName(options.Profile) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen"))
}
if options.Filesystem != nil {
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
}
if source.kind == sourceNone && options.Imports {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
}
if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile"))
}
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context"))
}
if definition.Version != nil {
if !definition.Version.Path.valid() {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))
}
if definition.Version.Check == nil {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
}
}
return options, canonicalDiagnostics(diagnostics)
}
func optionDiagnostic(code Code, message string) Diagnostic {
return Diagnostic{Code: code, Severity: SeverityError, Message: message}
}
func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
var diagnostics []Diagnostic
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
childPath := path.Key(entry.Key)
if entry.Key == "_configkit" {
location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column}
diagnostics = append(diagnostics, Diagnostic{
Code: CodeImportDisabled, Severity: SeverityError, Path: childPath,
Message: "configuration directives require imports to be enabled", Primary: &location,
})
}
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...)
}
} else if node.Kind == document.Sequence {
for index, child := range node.Sequence {
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...)
}
}
return diagnostics
}
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
if rule == nil {
return nil
}
version := Version{}
node, present := documentAtPath(root, rule.Path)
if present {
version.Present = true
switch node.Kind {
case document.Null:
version.Null = true
case document.String:
version.Text = node.Text
default:
location, _ := nearestDocumentLocation(root, rule.Path)
primary := documentLocationPointer(location)
return []Diagnostic{{
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
Message: "schema version must be a string or null", Primary: primary,
}}
}
}
state := &validationState{
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
invalidPath: CodeVersionError, document: root,
}
validation := &Validation{state: state}
err := rule.Check(version, validation)
state.active = false
if err != nil && !hasErrors(state.diagnostics) {
location, _ := nearestDocumentLocation(root, rule.Path)
state.diagnostics = append(state.diagnostics, Diagnostic{
Code: CodeVersionHookFailed, Severity: SeverityError, Path: rule.Path.clone(),
Message: "schema-version check failed", Primary: documentLocationPointer(location),
})
}
return canonicalDiagnostics(state.diagnostics)
}
func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
if root == nil || !path.valid() {
return nil, false
}
current := root
for _, segment := range path.segments {
switch current.Kind {
case document.Mapping:
if segment.kind == pathSegmentIndex {
return nil, false
}
found := false
for _, entry := range current.Mapping {
if entry.Key == segment.key {
current = entry.Value
found = true
break
}
}
if !found {
return nil, false
}
case document.Sequence:
index, ok := schemaIndex(segment)
if !ok || index >= len(current.Sequence) {
return nil, false
}
current = current.Sequence[index]
default:
return nil, false
}
}
return current, true
}
func documentLocationPointer(location document.Location) *Location {
if !locationKnown(location) {
return nil
}
return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
}
func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic {
return Diagnostic{Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message}
}
func diagnosticFromDecodeIssue(issue decodeIssue) Diagnostic {
return Diagnostic{
Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message,
Primary: documentLocationPointer(issue.location),
}
}
func hasErrors(diagnostics []Diagnostic) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Severity == SeverityError {
return true
}
}
return false
resolved.configuration = configuration
// Publication detaches equal-valued validator replacements, preserving the
// validated secret state, then finalizes metadata and sensitivity.
return publishLoadResult(schema, options.Definition, resolved, secretResolution.references, &accumulator)
}

88
load_input.go Normal file
View 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
}

View File

@@ -159,23 +159,6 @@ func collectFieldInfo(node *document.Node, path Path, decoded decodedConfigurati
}
}
func indexDocument(node *document.Node, path Path, index map[string]*document.Node) {
if node == nil {
return
}
index[path.String()] = node
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
indexDocument(entry.Value, path.Key(entry.Key), index)
}
case document.Sequence:
for position, child := range node.Sequence {
indexDocument(child, path.Index(position), index)
}
}
}
func cloneMetadata(metadata Metadata) Metadata {
if metadata.fields == nil && metadata.history == nil {
return Metadata{}
@@ -184,6 +167,14 @@ func cloneMetadata(metadata Metadata) Metadata {
fields: make(map[string]FieldInfo, len(metadata.fields)),
history: make(map[string][]Change, len(metadata.history)),
}
cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths))
for pointer, path := range metadata.effectivePaths {
cloned.effectivePaths[pointer] = path.clone()
}
cloned.historicalPaths = make(map[string][]Path, len(metadata.historicalPaths))
for pointer, paths := range metadata.historicalPaths {
cloned.historicalPaths[pointer] = clonePaths(paths)
}
for path, info := range metadata.fields {
cloned.fields[path] = cloneFieldInfo(info)
}

115
load_result.go Normal file
View 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),
}
}

View File

@@ -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 {
t.Fatalf("result, err = %#v, %v", result, err)
}
@@ -249,7 +249,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
t.Fatal(err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/_configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
t.Fatalf("diagnostic = %#v", diagnostic)
}
}

View File

@@ -1,16 +1,24 @@
package configkit
import (
"sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// Metadata provides detached effective presence/origin and chronological history
// queries for a load. Its zero value contains no paths.
type Metadata struct {
fields map[string]FieldInfo
history map[string][]Change
effectivePaths map[string]Path
historicalPaths map[string][]Path
fields map[string]FieldInfo
history map[string][]Change
}
// Lookup returns information for an effective path. Unknown and invalid paths
// return false.
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
if !path.valid() {
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) {
return FieldInfo{}, false
}
info, ok := m.fields[path.String()]
@@ -32,7 +40,7 @@ func (m Metadata) IsExplicit(path Path) bool {
// Removed historical paths can have history even though Lookup returns false.
// Unknown and invalid paths return nil.
func (m Metadata) History(path Path) []Change {
if !path.valid() {
if !m.hasHistoricalPath(path) {
return nil
}
history, ok := m.history[path.String()]
@@ -45,3 +53,88 @@ func (m Metadata) History(path Path) []Change {
}
return cloned
}
// HistoricalPaths returns the paths observed during loading, including effective
// paths and paths removed by overlays or recorded resolution. It includes default
// paths and the root, but not paths that never existed. The zero Metadata returns
// an empty slice.
//
// Paths are detached, unique by canonical JSON Pointer, and sorted by that
// spelling. Like ParsePath results, numeric tokens are resolved contextually;
// one pointer can identify a mapping key in an earlier layer and a sequence index
// in a later one. Use History to inspect definitions and Lookup to check whether
// a path remains effective. Enumeration exposes no configuration values and does
// not perform another load or resolve secrets.
func (m Metadata) HistoricalPaths() []Path {
pointers := make([]string, 0, len(m.historicalPaths))
for pointer := range m.historicalPaths {
pointers = append(pointers, pointer)
}
sort.Strings(pointers)
paths := make([]Path, 0, len(pointers))
for _, pointer := range pointers {
// The index contains only canonical pointers produced by Path.String.
path, _ := ParsePath(pointer)
paths = append(paths, path)
}
return paths
}
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
// the query. Candidate paths are constructed from actual document containers.
func matchesPathShape(query, candidate Path) bool {
if !query.valid() || query.String() != candidate.String() || len(query.segments) != len(candidate.segments) {
return false
}
for i, segment := range query.segments {
actual := candidate.segments[i]
if actual.kind == pathSegmentIndex {
index, ok := schemaIndex(segment)
if !ok || index != actual.index {
return false
}
} else if segment.kind == pathSegmentIndex {
return false
}
}
return true
}
func (m Metadata) hasHistoricalPath(path Path) bool {
if !path.valid() {
return false
}
for _, candidate := range m.historicalPaths[path.String()] {
if matchesPathShape(path, candidate) {
return true
}
}
return false
}
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
m.effectivePaths = make(map[string]Path)
m.historicalPaths = make(map[string][]Path)
walkDocument(final, Path{}, func(path Path, _ *document.Node) { m.effectivePaths[path.String()] = path.clone() })
add := func(path Path, _ *document.Node) {
pointer := path.String()
for _, prior := range m.historicalPaths[pointer] {
if matchesPathShape(path, prior) {
return
}
}
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
}
walkDocument(final, Path{}, add)
for _, tree := range earlier {
walkDocument(tree, Path{}, add)
}
for _, application := range applications {
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
prefix := Path{}
for _, segment := range application.path.segments {
prefix = prefix.Key(segment.key)
}
walkDocument(application.value, prefix, add)
}
}

View File

@@ -1,12 +1,103 @@
package configkit
import "testing"
import (
"bytes"
"reflect"
"testing"
)
func TestMetadataHistoricalPaths(t *testing.T) {
t.Parallel()
result, err := Load[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{
Overrides: []Override{
{Path: Keys("retired"), Value: nil, Flag: "--retired"},
{Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
},
})
if err != nil {
t.Fatal(err)
}
metadata := result.Metadata()
paths := metadata.HistoricalPaths()
var got []string
for _, path := range paths {
got = append(got, path.String())
if len(metadata.History(path)) == 0 {
t.Fatalf("enumerated path %s has no history", path)
}
}
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("historical paths = %v, want %v", got, want)
}
if _, ok := metadata.Lookup(paths[4]); ok {
t.Fatal("removed descendant remains effective")
}
if _, ok := metadata.Lookup(paths[6]); !ok {
t.Fatal("enumerated numeric path cannot query the new sequence shape")
}
// Returned slices and nested paths must not alias retained metadata.
paths[1].segments[0].key = "mutated"
paths[2] = Keys("replacement")
if again := metadata.HistoricalPaths(); again[1].String() != want[1] || again[2].String() != want[2] {
t.Fatal("enumeration returned aliased paths")
}
if len((Metadata{}).HistoricalPaths()) != 0 {
t.Fatal("zero metadata contains paths")
}
}
func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *testing.T) {
t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_KEY", "")
t.Setenv("CONFIGKIT_HISTORY_TEST_KEY", "synthetic-test-key")
type config struct {
Credential *Secret `yaml:"credential"`
Definitions map[string]map[string]string `yaml:"definitions"`
}
for _, credential := range []string{"CONFIGKIT_HISTORY_TEST_KEY", "null"} {
t.Run(credential, func(t *testing.T) {
dir := t.TempDir()
root := writeImportFile(t, dir, "main.yml", "configkit:\n imports: [{path: first.yml}, {path: second.yml}]\ncredential: CONFIGKIT_HISTORY_UNUSED_PROD_KEY\n")
writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n")
writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n")
writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n")
result, err := Load[config](File(root), Options[config]{Imports: true, Profile: "testing"})
if err != nil {
t.Fatal(err)
}
metadata := result.Metadata()
found := false
for _, path := range metadata.HistoricalPaths() {
if path.String() != "/definitions/recap" {
continue
}
found = true
owners := map[string]bool{}
for _, change := range metadata.History(path) {
if change.Origin.Layer == LayerBase {
for _, location := range change.Origin.Locations {
owners[location.SourceID] = true
}
}
}
if len(owners) != 2 {
t.Fatalf("hidden declaration owners = %v", owners)
}
}
if !found {
t.Fatal("hidden base declaration was not discoverable")
}
})
}
}
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
t.Parallel()
path := Keys("service", "workers")
metadata := Metadata{
effectivePaths: map[string]Path{path.String(): path},
historicalPaths: map[string][]Path{path.String(): {path}},
fields: map[string]FieldInfo{
path.String(): {
Explicit: true,
@@ -56,3 +147,45 @@ func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
t.Fatal("invalid path produced metadata")
}
}
func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) {
result, err := Load[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}})
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("items", "0"), Keys("m").Index(0), Keys("x", "missing"), Keys("items").Index(8)} {
if _, ok := result.Metadata().Lookup(path); ok {
t.Fatalf("invalid lookup %s", path)
}
if result.Metadata().History(path) != nil {
t.Fatalf("invalid history %s", path)
}
if _, ok := result.Explain(path); ok {
t.Fatalf("invalid explanation %s", path)
}
var out bytes.Buffer
if err := result.WriteExplanation(&out, path); err == nil || out.Len() != 0 {
t.Fatalf("invalid path wrote output: %s", path)
}
}
for _, pointer := range []string{"/items/0", "/m/0"} {
path, _ := ParsePath(pointer)
if _, ok := result.Metadata().Lookup(path); !ok {
t.Fatal("parsed path lost lookup")
}
if _, ok := result.Explain(path); !ok {
t.Fatal("parsed path lost explanation")
}
}
retired := Keys("retired", "child")
if _, ok := result.Metadata().Lookup(retired); ok {
t.Fatal("removed path remains effective")
}
if len(result.Metadata().History(retired)) == 0 {
t.Fatal("removed history missing")
}
x, ok := result.Explain(retired)
if !ok || x.Effective.Present || len(x.History) == 0 {
t.Fatal("removed explanation missing")
}
}

41
options.go Normal file
View 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}
}

View File

@@ -10,8 +10,6 @@ import (
type overlayRecord struct {
change Change
displaced []displacedDefinition
before *document.Node
after *document.Node
}
type displacedDefinition struct {
@@ -26,15 +24,7 @@ func overlayDocuments(base *composedDocument, overlay *document.Node) (*composed
Message: "profile overlay requires mapping document roots",
}}
}
result := &composedDocument{
root: document.Clone(base.root),
claims: cloneCompositionClaims(base.claims),
origins: cloneOriginIndex(base.origins),
overlays: cloneOverlayRecords(base.overlays),
applications: cloneHistoryApplications(base.applications),
inputBytes: base.inputBytes,
inputNodes: base.inputNodes,
}
result := cloneComposedDocument(base)
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
@@ -73,7 +63,6 @@ func mergeOverlayMappings(result *composedDocument, target, overlay *document.No
Previous: previousOrigins(previous),
},
displaced: displaced,
before: document.Clone(existing), after: document.Clone(incoming.Value),
})
}
}
@@ -161,8 +150,6 @@ func cloneOverlayRecords(records []overlayRecord) []overlayRecord {
cloned := make([]overlayRecord, len(records))
for index, record := range records {
cloned[index].change = cloneChange(record.change)
cloned[index].before = document.Clone(record.before)
cloned[index].after = document.Clone(record.after)
cloned[index].displaced = make([]displacedDefinition, len(record.displaced))
for displacedIndex, displaced := range record.displaced {
cloned[index].displaced[displacedIndex] = displacedDefinition{

View File

@@ -1,9 +1,6 @@
package configkit
import (
"math"
"reflect"
"slices"
"strconv"
"strings"
"unicode"
@@ -22,17 +19,7 @@ type preparedOverride struct {
nodes int
}
type overrideValueBuilder struct {
sourceID string
active map[valueVisit]bool
bytes int64
nodes int
}
func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
if len(overrides) == 0 {
return base, cloneSourceInfos(sources), nil
}
func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) ([]preparedOverride, []SourceInfo, []Diagnostic) {
if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources {
return nil, cloneSourceInfos(sources), []Diagnostic{{
Code: CodeOverrideLimitExceeded, Severity: SeverityError,
@@ -147,13 +134,7 @@ func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Ov
return nil, allSources, canonicalDiagnostics(diagnostics)
}
result := cloneComposedDocument(base)
for _, override := range prepared {
applyPreparedOverride(result, override)
}
result.inputBytes += usedBytes
result.inputNodes += usedNodes
return result, allSources, nil
return prepared, allSources, nil
}
func validOverrideLabel(label string) bool {
@@ -207,7 +188,7 @@ func overlappingOverride(previous []Override, valid []bool, path Path) int {
func pathContainsDirective(path Path) bool {
for _, segment := range path.segments {
if segment.key == "_configkit" {
if segment.key == directiveKey {
return true
}
}
@@ -224,7 +205,7 @@ func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
child := path.Key(entry.Key)
if entry.Key == "_configkit" {
if entry.Key == directiveKey {
return child, true
}
if found, ok := overrideDirectivePath(entry.Value, child); ok {
@@ -240,223 +221,3 @@ func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
}
return Path{}, false
}
func (b *overrideValueBuilder) build(value any, path Path, depth int) (*document.Node, *Diagnostic) {
if depth > maxDocumentDepth {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the depth limit")
}
b.nodes++
if b.nodes > maxDocumentNodes {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
}
location := document.Location{SourceID: b.sourceID}
switch value := value.(type) {
case nil:
return &document.Node{Kind: document.Null, Location: location}, nil
case string:
if !b.addBytes(len(value)) {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the record string-byte limit")
}
return &document.Node{Kind: document.String, Text: value, Location: location}, nil
case bool:
return &document.Node{Kind: document.Boolean, Text: strconv.FormatBool(value), Location: location}, nil
case int:
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
case int8:
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
case int16:
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
case int32:
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
case int64:
return overrideNumber(strconv.FormatInt(value, 10), location), nil
case uint:
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
case uint8:
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
case uint16:
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
case uint32:
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
case uint64:
return overrideNumber(strconv.FormatUint(value, 10), location), nil
case float32:
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
}
return overrideNumber(strconv.FormatFloat(float64(value), 'g', -1, 32), location), nil
case float64:
if math.IsNaN(value) || math.IsInf(value, 0) {
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
}
return overrideNumber(strconv.FormatFloat(value, 'g', -1, 64), location), nil
case []any:
if value == nil {
return &document.Node{Kind: document.Null, Location: location}, nil
}
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
if diagnostic != nil {
return nil, diagnostic
}
defer leave()
if len(value) > maxDocumentNodes-b.nodes {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
}
node := &document.Node{Kind: document.Sequence, Location: location, Sequence: make([]*document.Node, len(value))}
for index, child := range value {
built, diagnostic := b.build(child, path.Index(index), depth+1)
if diagnostic != nil {
return nil, diagnostic
}
node.Sequence[index] = built
}
return node, nil
case map[string]any:
if value == nil {
return &document.Node{Kind: document.Null, Location: location}, nil
}
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
if diagnostic != nil {
return nil, diagnostic
}
defer leave()
if len(value) > (maxDocumentNodes-b.nodes)/2 {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
}
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
slices.Sort(keys)
node := &document.Node{Kind: document.Mapping, Location: location, Mapping: make([]document.Entry, 0, len(keys))}
for _, key := range keys {
b.nodes++ // mapping key
if !b.addBytes(len(key)) {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path.Key(key), "configuration override exceeds the record string-byte limit")
}
built, diagnostic := b.build(value[key], path.Key(key), depth+1)
if diagnostic != nil {
return nil, diagnostic
}
node.Mapping = append(node.Mapping, document.Entry{Key: key, KeyLocation: location, ValueLocation: location, Value: built})
}
return node, nil
default:
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override value has an unsupported type")
}
}
func (b *overrideValueBuilder) addBytes(count int) bool {
if count < 0 || int64(count) > maxSourceBytes-b.bytes {
return false
}
b.bytes += int64(count)
return true
}
func (b *overrideValueBuilder) enter(value reflect.Value, path Path) (func(), *Diagnostic) {
visit, ok := visitFor(value)
if !ok {
return func() {}, nil
}
if b.active[visit] {
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override values must not contain cycles")
}
b.active[visit] = true
return func() { delete(b.active, visit) }, nil
}
func overrideValueDiagnostic(code Code, path Path, message string) *Diagnostic {
return &Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
}
func overrideNumber(text string, location document.Location) *document.Node {
return &document.Node{Kind: document.Number, Text: text, Location: location}
}
func cloneComposedDocument(base *composedDocument) *composedDocument {
return &composedDocument{
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
applications: cloneHistoryApplications(base.applications),
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
}
}
func applyPreparedOverride(result *composedDocument, override preparedOverride) {
location := document.Location{SourceID: override.source.ID}
mergeOverlayOrigin(result.origins, Path{}, location, LayerCLI)
current := result.root
currentPath := Path{}
for index, segment := range override.path.segments {
path := currentPath.Key(segment.key)
position := documentEntryIndex(current.Mapping, segment.key)
if index < len(override.path.segments)-1 {
if position < 0 {
child := &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{}}
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: child})
collectOverlayOrigins(result.origins, child, path, LayerCLI)
current = child
} else {
current = current.Mapping[position].Value
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
}
currentPath = path
continue
}
previous := cloneOrigin(result.origins[path.String()])
var displaced []displacedDefinition
var before *document.Node
if position < 0 {
incoming := document.Clone(override.value)
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: incoming})
collectOverlayOrigins(result.origins, incoming, path, LayerCLI)
} else {
existing := current.Mapping[position].Value
before = document.Clone(existing)
displaced = affectedOverrideOrigins(existing, override.value, result.origins, path)
if existing.Kind == document.Mapping && override.value.Kind == document.Mapping {
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
mergeOverlayMappings(result, existing, override.value, path, LayerCLI, false)
} else {
deleteOriginSubtree(result.origins, path)
current.Mapping[position] = document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: document.Clone(override.value)}
collectOverlayOrigins(result.origins, current.Mapping[position].Value, path, LayerCLI)
}
}
position = documentEntryIndex(current.Mapping, segment.key)
result.overlays = append(result.overlays, overlayRecord{change: Change{
Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous),
}, displaced: displaced, before: before, after: document.Clone(current.Mapping[position].Value)})
result.applications = append(result.applications, historyApplication{
layer: LayerCLI, path: path.clone(), value: override.value,
origin: overlayOrigin(LayerCLI, location),
})
}
}
func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
displaced := []displacedDefinition{{path: path.clone(), origin: cloneOrigin(origins[path.String()])}}
if existing.Kind == document.Mapping && incoming.Kind == document.Mapping {
for _, entry := range incoming.Mapping {
position := documentEntryIndex(existing.Mapping, entry.Key)
if position < 0 {
continue
}
childPath := path.Key(entry.Key)
childExisting := existing.Mapping[position].Value
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
displaced = append(displaced, affectedOverrideOrigins(childExisting, entry.Value, origins, childPath)...)
} else {
displaced = append(displaced, displacedOrigins(childExisting, origins, childPath)...)
}
}
} else {
displaced = displacedOrigins(existing, origins, path)
}
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
return strings.Compare(left.path.String(), right.path.String())
})
return displaced
}

100
override_apply.go Normal file
View 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
}

View File

@@ -30,7 +30,7 @@ type overrideLifecycleConfig struct {
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
writeImportFile(t, directory, "base.yml", `settings:
target: base
count: 4
@@ -187,8 +187,8 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
{name: "depth", overrides: []Override{{Path: Keys("nested"), Value: deep, Flag: "--nested"}}, code: CodeOverrideLimitExceeded},
{name: "record bytes", overrides: []Override{{Path: Keys("scalar"), Value: strings.Repeat("x", maxSourceBytes+1), Flag: "--scalar"}}, code: CodeOverrideLimitExceeded},
{name: "directive path", overrides: []Override{{Path: Keys("_configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"_configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
{name: "directive path", overrides: []Override{{Path: Keys("configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
{name: "version", overrides: []Override{{Path: Keys("version"), Value: "v2", Flag: "--version"}}, code: CodeOverrideVersion},
{name: "version ancestor", overrides: []Override{{Path: Keys("version"), Value: map[string]any{"child": "v2"}, Flag: "--version"}}, code: CodeOverrideVersion},
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},

150
override_value.go Normal file
View 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}
}

View File

@@ -33,6 +33,21 @@ type parsedSource struct {
bytes int64
}
// parserDiagnostics translates adapter issues into detached, ordered public records.
func parserDiagnostics(sourceID string, issues []yamladapter.Issue) []Diagnostic {
diagnostics := make([]Diagnostic, len(issues))
for index, issue := range issues {
diagnostics[index] = Diagnostic{
Code: Code(issue.Code), Severity: SeverityError, Message: issue.Message,
Primary: &Location{SourceID: sourceID, Line: issue.Line, Column: issue.Column},
}
for _, related := range issue.Related {
diagnostics[index].Related = append(diagnostics[index].Related, publicLocation(related))
}
}
return canonicalDiagnostics(diagnostics)
}
func parseSource(source Source, sourceID string, limits parseLimits) (parsedSource, []Diagnostic) {
read, diagnostics := readSource(source, sourceID, limits.source)
if len(diagnostics) != 0 || read.info == nil {
@@ -41,27 +56,7 @@ func parseSource(source Source, sourceID string, limits parseLimits) (parsedSour
root, issues := yamladapter.Parse(bytes.NewReader(read.data), sourceID, limits.document)
if len(issues) != 0 {
diagnostics := make([]Diagnostic, len(issues))
for i, issue := range issues {
diagnostics[i] = Diagnostic{
Code: Code(issue.Code),
Severity: SeverityError,
Message: issue.Message,
Primary: &Location{
SourceID: sourceID,
Line: issue.Line,
Column: issue.Column,
},
}
for _, related := range issue.Related {
diagnostics[i].Related = append(diagnostics[i].Related, Location{
SourceID: related.SourceID,
Line: related.Line,
Column: related.Column,
})
}
}
return parsedSource{source: read.info, bytes: int64(len(read.data))}, canonicalDiagnostics(diagnostics)
return parsedSource{source: read.info, bytes: int64(len(read.data))}, parserDiagnostics(sourceID, issues)
}
return parsedSource{document: root, source: read.info, bytes: int64(len(read.data))}, nil
}

View File

@@ -3,14 +3,12 @@ package configkit
import (
"bytes"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
@@ -68,9 +66,14 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS
value.Primary = nil
return nil, cloneSourceInfos(sources), []Diagnostic{value}
}
rootClosed := false
defer func() {
if !rootClosed {
_ = root.Close()
}
}()
relative, err := filepath.Rel(rootPath, canonical)
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
_ = root.Close()
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile could not be addressed inside the configuration root", nil)}
}
@@ -80,32 +83,29 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS
sources = append(cloneSourceInfos(sources), profileSource)
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
if diagnostic != nil {
_ = root.Close()
return nil, sources, []Diagnostic{*diagnostic}
}
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
if len(issues) != 0 {
_ = root.Close()
return nil, sources, importParseDiagnostics(sourceID, issues)
return nil, sources, parserDiagnostics(sourceID, issues)
}
nodeCount := countInputNodes(profileRoot)
if base.inputNodes+nodeCount > limits.nodes {
_ = root.Close()
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
}
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
_ = root.Close()
return nil, sources, diagnostics
}
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
location := publicLocation(versionNode.Location)
_ = root.Close()
return nil, sources, []Diagnostic{{
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location,
}}
}
if err := root.Close(); err != nil {
closeErr := root.Close()
rootClosed = true
if closeErr != nil {
return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected profile", nil)}
}
@@ -122,58 +122,10 @@ func readProfileFile(root *os.Root, relative, sourceID string, remaining int64)
if limit < 0 {
limit = 0
}
file, err := root.Open(relative)
if err != nil {
value := classifySourceError(err, sourceID)
return nil, &value
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
value := classifySourceError(err, sourceID)
return nil, &value
}
if !info.Mode().IsRegular() {
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
return nil, &value
}
if info.Size() > limit {
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
return nil, &value
}
data, err := io.ReadAll(io.LimitReader(file, limit+1))
if err != nil {
value := classifySourceError(err, sourceID)
return nil, &value
}
if int64(len(data)) > limit {
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
data, failure := readRegularFile(relative, root.Stat, root.Open, limit)
if failure != nil {
value := sourceReadDiagnostic(failure, sourceID, CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit")
return nil, &value
}
return data, nil
}
func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
childPath := current.Key(entry.Key)
if entry.Key == "_configkit" {
location := publicLocation(entry.KeyLocation)
return []Diagnostic{{
Code: CodeProfileDirective, Severity: SeverityError, Path: childPath,
Message: "profiles cannot contain configuration directives", Primary: &location,
}}
}
if diagnostics := profileDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
return diagnostics
}
}
} else if node.Kind == document.Sequence {
for index, child := range node.Sequence {
if diagnostics := profileDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
return diagnostics
}
}
}
return nil
}

View File

@@ -23,7 +23,7 @@ type profileTestConfig struct {
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports: [{path: base.yml}]
settings:
kept: main
@@ -133,8 +133,8 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
profile string
code Code
}{
{name: "root directive", profile: "_configkit: {}\n", code: CodeProfileDirective},
{name: "nested directive", profile: "settings: {_configkit: {}}\n", code: CodeProfileDirective},
{name: "root directive", profile: "configkit: {}\n", code: CodeProfileDirective},
{name: "nested directive", profile: "settings: {configkit: {}}\n", code: CodeProfileDirective},
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
{name: "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) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: profiles/testing.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/testing.yml}]}\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
})

View File

@@ -1,6 +1,7 @@
package configkit
import (
"cmp"
"encoding"
"math"
"reflect"
@@ -12,34 +13,6 @@ import (
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type projectionLimits struct {
depth int
nodes int
}
func defaultProjectionLimits() projectionLimits {
return projectionLimits{depth: maxDocumentDepth, nodes: maxDocumentNodes}
}
type projectionIssue struct {
code Code
path Path
message string
}
type valueVisit struct {
typeOf reflect.Type
kind reflect.Kind
ptr uintptr
}
type valueOperation struct {
schema *schemaDescriptor
limits projectionLimits
active map[valueVisit]bool
nodes int
}
func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (*document.Node, *projectionIssue) {
if schema == nil || schema.root == nil || !value.IsValid() || value.Type() != schema.root.typeOf {
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
@@ -48,14 +21,6 @@ func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits
return operation.project(value, schema.root, Path{}, 0)
}
func cloneConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (reflect.Value, *projectionIssue) {
if schema == nil || schema.root == nil || !value.IsValid() || value.Type() != schema.root.typeOf {
return reflect.Value{}, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
}
operation := valueOperation{schema: schema, limits: limits, active: make(map[valueVisit]bool)}
return operation.clone(value, schema.root, Path{}, 0)
}
func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
if issue := o.checkDepth(path, depth); issue != nil {
return nil, issue
@@ -117,6 +82,15 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
seen[member.name] = true
result.Mapping = append(result.Mapping, document.Entry{Key: member.name, Value: child})
}
for _, entry := range result.Mapping {
field := descriptor.inlineMap
if index, ok := descriptor.fieldByName[entry.Key]; ok {
field = &descriptor.fields[index]
}
if field != nil && field.secret {
entry.Value.Sensitive = true
}
}
return result, nil
case descriptorMap:
if value.IsNil() {
@@ -134,7 +108,7 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
result.Mapping = make([]document.Entry, 0, value.Len())
keys := value.MapKeys()
slices.SortFunc(keys, func(left, right reflect.Value) int {
return cmpString(left.String(), right.String())
return cmp.Compare(left.String(), right.String())
})
for _, key := range keys {
childPath := path.Key(key.String())
@@ -243,169 +217,6 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
}
}
func (o *valueOperation) clone(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (reflect.Value, *projectionIssue) {
if issue := o.checkDepth(path, depth); issue != nil {
return reflect.Value{}, issue
}
if descriptor.kind == descriptorPointer {
if value.IsNil() {
return reflect.Zero(value.Type()), nil
}
leave, issue := o.enter(value, path)
if issue != nil {
return reflect.Value{}, issue
}
defer leave()
child, issue := o.clone(value.Elem(), descriptor.element, path, depth)
if issue != nil {
return reflect.Value{}, issue
}
result := reflect.New(value.Type().Elem())
result.Elem().Set(child)
return result, nil
}
switch descriptor.kind {
case descriptorStruct:
result := reflect.New(value.Type()).Elem()
for _, member := range descriptor.members {
memberPath, memberDepth := path, depth
if !member.inline {
memberPath, memberDepth = path.Key(member.name), depth+1
}
cloned, issue := o.clone(value.Field(member.index), member.descriptor, memberPath, memberDepth)
if issue != nil {
return reflect.Value{}, issue
}
result.Field(member.index).Set(cloned)
}
return result, nil
case descriptorMap:
if value.IsNil() {
return reflect.Zero(value.Type()), nil
}
leave, issue := o.enter(value, path)
if issue != nil {
return reflect.Value{}, issue
}
defer leave()
result := reflect.MakeMapWithSize(value.Type(), value.Len())
for _, key := range value.MapKeys() {
cloned, issue := o.clone(value.MapIndex(key), descriptor.element, path.Key(key.String()), depth+1)
if issue != nil {
return reflect.Value{}, issue
}
result.SetMapIndex(key, cloned)
}
return result, nil
case descriptorSlice:
if value.IsNil() {
return reflect.Zero(value.Type()), nil
}
leave, issue := o.enter(value, path)
if issue != nil {
return reflect.Value{}, issue
}
defer leave()
result := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
for i := 0; i < value.Len(); i++ {
cloned, issue := o.clone(value.Index(i), descriptor.element, path.Index(i), depth+1)
if issue != nil {
return reflect.Value{}, issue
}
result.Index(i).Set(cloned)
}
return result, nil
case descriptorArray:
result := reflect.New(value.Type()).Elem()
for i := 0; i < value.Len(); i++ {
cloned, issue := o.clone(value.Index(i), descriptor.element, path.Index(i), depth+1)
if issue != nil {
return reflect.Value{}, issue
}
result.Index(i).Set(cloned)
}
return result, nil
case descriptorDynamic:
if value.IsNil() {
return reflect.Zero(value.Type()), nil
}
concrete := value.Elem()
dynamic, issue := o.dynamicDescriptor(concrete.Type(), path)
if issue != nil {
return reflect.Value{}, issue
}
cloned, issue := o.clone(concrete, dynamic, path, depth)
if issue != nil {
return reflect.Value{}, issue
}
result := reflect.New(value.Type()).Elem()
result.Set(cloned)
return result, nil
case descriptorSecret:
result := reflect.New(value.Type()).Elem()
reference := value.Interface().(Secret)
if reference.specified {
result.Set(reflect.ValueOf(SecretEnv(reference.EnvName())))
}
return result, nil
case descriptorText:
text, issue := marshalCanonicalText(value, path)
if issue != nil {
return reflect.Value{}, issue
}
result := reflect.New(value.Type()).Elem()
unmarshaler := result.Addr().Interface().(encoding.TextUnmarshaler)
if err := unmarshaler.UnmarshalText([]byte(text)); err != nil {
return reflect.Value{}, &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar could not be reconstructed"}
}
return result, nil
case descriptorFloat:
if math.IsNaN(value.Float()) || math.IsInf(value.Float(), 0) {
return reflect.Value{}, &projectionIssue{code: CodeSchemaNonFinite, path: path.clone(), message: "configuration float must be finite"}
}
fallthrough
default:
result := reflect.New(value.Type()).Elem()
result.Set(value)
return result, nil
}
}
func (o *valueOperation) dynamicDescriptor(current reflect.Type, path Path) (*typeDescriptor, *projectionIssue) {
if current == secretType {
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"}
}
builder := schemaBuilder{cache: o.schema.types, building: make(map[reflect.Type]bool)}
descriptor, issue := builder.describe(current, path)
if issue != nil {
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration value has an unsupported type"}
}
if descriptorContainsSecret(descriptor, make(map[*typeDescriptor]bool)) {
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"}
}
return descriptor, nil
}
func descriptorContainsSecret(descriptor *typeDescriptor, visited map[*typeDescriptor]bool) bool {
if descriptor == nil || visited[descriptor] {
return false
}
if descriptor.kind == descriptorSecret {
return true
}
visited[descriptor] = true
if descriptorContainsSecret(descriptor.element, visited) {
return true
}
for _, member := range descriptor.members {
if descriptorContainsSecret(member.descriptor, visited) {
return true
}
}
return false
}
func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*document.Node, *projectionIssue) {
if issue := o.checkDepth(path, depth); issue != nil {
return nil, issue
@@ -416,64 +227,6 @@ func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*docume
return &document.Node{Kind: kind}, nil
}
func (o *valueOperation) charge(path Path) *projectionIssue {
o.nodes++
if o.limits.nodes >= 0 && o.nodes > o.limits.nodes {
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the node limit"}
}
return nil
}
func (o *valueOperation) checkDepth(path Path, depth int) *projectionIssue {
if depth > o.limits.depth {
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the depth limit"}
}
return nil
}
func (o *valueOperation) enter(value reflect.Value, path Path) (func(), *projectionIssue) {
visit, ok := visitFor(value)
if !ok {
return func() {}, nil
}
if o.active[visit] {
return nil, &projectionIssue{code: CodeSchemaCycle, path: path.clone(), message: "configuration values must not contain cycles"}
}
o.active[visit] = true
return func() { delete(o.active, visit) }, nil
}
func visitFor(value reflect.Value) (valueVisit, bool) {
if isNilValue(value) {
return valueVisit{}, false
}
visit := valueVisit{typeOf: value.Type(), kind: value.Kind()}
switch value.Kind() {
case reflect.Pointer, reflect.Slice:
if value.Kind() == reflect.Slice && value.Len() == 0 {
return valueVisit{}, false
}
visit.ptr = value.Pointer()
case reflect.Map:
if value.Len() == 0 {
return valueVisit{}, false
}
visit.ptr = uintptr(value.UnsafePointer())
default:
return valueVisit{}, false
}
return visit, visit.ptr != 0
}
func isNilValue(value reflect.Value) bool {
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return value.IsNil()
default:
return false
}
}
func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIssue) {
var marshaler encoding.TextMarshaler
if value.CanInterface() {
@@ -493,113 +246,3 @@ func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIs
}
return string(text), nil
}
func cmpString(left, right string) int {
switch {
case left < right:
return -1
case left > right:
return 1
default:
return 0
}
}
type documentDeltaKind uint8
const (
documentAdded documentDeltaKind = iota + 1
documentRemoved
documentReplaced
)
type documentDelta struct {
path Path
kind documentDeltaKind
}
func compareDocumentTrees(before, after *document.Node) []documentDelta {
var deltas []documentDelta
compareDocumentNode(before, after, Path{}, &deltas)
slices.SortFunc(deltas, func(left, right documentDelta) int {
if order := cmpString(left.path.String(), right.path.String()); order != 0 {
return order
}
return int(left.kind) - int(right.kind)
})
return deltas
}
func compareDocumentNode(before, after *document.Node, path Path, deltas *[]documentDelta) {
if before == nil && after == nil {
return
}
if before == nil {
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentAdded})
return
}
if after == nil {
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentRemoved})
return
}
if before.Kind != after.Kind {
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
return
}
switch before.Kind {
case document.Mapping:
beforeEntries := make(map[string]*document.Node, len(before.Mapping))
afterEntries := make(map[string]*document.Node, len(after.Mapping))
keys := make(map[string]bool, len(before.Mapping)+len(after.Mapping))
for _, entry := range before.Mapping {
beforeEntries[entry.Key] = entry.Value
keys[entry.Key] = true
}
for _, entry := range after.Mapping {
afterEntries[entry.Key] = entry.Value
keys[entry.Key] = true
}
ordered := make([]string, 0, len(keys))
for key := range keys {
ordered = append(ordered, key)
}
slices.Sort(ordered)
for _, key := range ordered {
compareDocumentNode(beforeEntries[key], afterEntries[key], path.Key(key), deltas)
}
case document.Sequence:
if !documentNodesEqual(before, after) {
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
}
default:
if before.Text != after.Text {
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
}
}
}
func documentNodesEqual(left, right *document.Node) bool {
if left == nil || right == nil {
return left == right
}
if left.Kind != right.Kind || left.Text != right.Text || len(left.Mapping) != len(right.Mapping) || len(left.Sequence) != len(right.Sequence) {
return false
}
if left.Kind == document.Mapping {
leftMap := make(map[string]*document.Node, len(left.Mapping))
for _, entry := range left.Mapping {
leftMap[entry.Key] = entry.Value
}
for _, entry := range right.Mapping {
if !documentNodesEqual(leftMap[entry.Key], entry.Value) {
return false
}
}
}
for i := range left.Sequence {
if !documentNodesEqual(left.Sequence[i], right.Sequence[i]) {
return false
}
}
return true
}

View File

@@ -328,7 +328,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
if issue != nil {
t.Fatal(issue.message)
}
deltas := compareDocumentTrees(before, after)
deltas := compareConfigurationValues(before, after)
want := []struct {
path string
kind documentDeltaKind
@@ -352,7 +352,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
t.Fatal("sequence comparison created index correspondence")
}
}
if equal := compareDocumentTrees(after, document.Clone(after)); len(equal) != 0 {
if equal := compareConfigurationValues(after, document.Clone(after)); len(equal) != 0 {
t.Fatalf("equal trees changed = %#v", equal)
}
}

View File

@@ -27,7 +27,7 @@ type layeredConfig struct {
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports: [{path: imported.yml}]
shared: {equal: same}
values: [1, 2]
@@ -138,7 +138,7 @@ empty: {}
t.Fatal(issue)
}
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
if projectionIssue != nil || !documentNodesEqual(projected, result.final) {
if projectionIssue != nil || !configurationValuesEqual(projected, result.final) {
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
}

View File

@@ -11,11 +11,14 @@ const (
LayerResolution Layer = "resolution"
)
// ChangeKind identifies an application resolution operation. The zero value is
// ChangeKind identifies a recorded operation. The zero value is
// used for input and default origins.
type ChangeKind string
const (
// ChangeDecoding records the built-in transition to canonical typed values.
// It is inspection metadata, not an operation accepted by Resolution.Record.
ChangeDecoding ChangeKind = "decoding"
ChangeDerivedDefault ChangeKind = "derived_default"
ChangeNormalization ChangeKind = "normalization"
)

108
release_check_test.go Normal file
View 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")
}
})
}
})
}
}

View File

@@ -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")
}

View File

@@ -241,7 +241,7 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
}
deltas := compareDocumentTrees(decoded.final, after)
deltas := compareConfigurationValues(decoded.final, after)
ordered := orderedResolutionRecords(state.records)
for _, delta := range deltas {
if coveringResolutionRecord(ordered, delta.path) == nil {
@@ -249,16 +249,15 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
}
}
for _, record := range ordered {
beforeNode, beforePresent := documentAtPath(decoded.final, record.path)
_, beforePresent := documentAtPath(decoded.final, record.path)
afterNode, afterPresent := documentAtPath(after, record.path)
if !beforePresent && !afterPresent {
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
continue
}
if record.helperExpected != nil && (!afterPresent || !documentNodesEqual(record.helperExpected, afterNode)) {
if record.helperExpected != nil && (!afterPresent || !configurationValuesEqual(record.helperExpected, afterNode)) {
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
}
_ = beforeNode
}
if hasErrors(state.diagnostics) {
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
@@ -343,17 +342,3 @@ func clonePaths(paths []Path) []Path {
}
return cloned
}
func cloneResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
if snapshots == nil {
return nil
}
cloned := make([]resolutionSnapshot, len(snapshots))
for index, snapshot := range snapshots {
cloned[index] = resolutionSnapshot{
change: cloneChange(snapshot.change),
before: document.Clone(snapshot.before), after: document.Clone(snapshot.after),
}
}
return cloned
}

View File

@@ -212,9 +212,17 @@ func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current refle
return &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "inline field cannot declare a YAML name"}
}
inlineType := field.Type
seenPointers := make(map[reflect.Type]bool)
for inlineType.Kind() == reflect.Pointer {
if seenPointers[inlineType] {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline pointer chain must terminate in a struct"}
}
seenPointers[inlineType] = true
inlineType = inlineType.Elem()
}
if inlineType.Kind() == reflect.Map && field.Type.Kind() == reflect.Pointer {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline maps cannot be pointers"}
}
switch inlineType.Kind() {
case reflect.Struct:
if b.building[inlineType] {

View File

@@ -1,6 +1,7 @@
package configkit
import (
"errors"
"reflect"
"testing"
"time"
@@ -302,3 +303,40 @@ func mustPath(t *testing.T, pointer string) Path {
}
return path
}
func TestInlineMapPointersFailBeforeDefaults(t *testing.T) {
type cfg struct {
Extra *map[string]int `yaml:",inline"`
}
for _, nonNil := range []bool{false, true} {
called := false
_, err := Load(NoSource(), Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg {
called = true
if nonNil {
m := map[string]int{"value": 1}
return cfg{Extra: &m}
}
return cfg{}
}}})
var loadErr *LoadError
if !errors.As(err, &loadErr) || called {
t.Fatalf("invalid inline schema reached defaults: called=%v error=%v", called, err)
}
if len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != CodeSchemaInline {
t.Fatal("missing inline schema diagnostic")
}
}
type pointer *pointer
type recursive struct {
Extra pointer `yaml:",inline"`
}
if _, err := Load[recursive](NoSource()); err == nil {
t.Fatal("recursive inline pointer was accepted")
}
type double struct {
Extra **map[string]int `yaml:",inline"`
}
if _, err := Load[double](NoSource()); err == nil {
t.Fatal("double inline map pointer was accepted")
}
}

41
scripts/check.sh Executable file
View 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 ./...

View File

@@ -25,8 +25,13 @@ bash -n scripts/release-check.sh
temporary_dir=$(mktemp -d)
trap 'rm -rf -- "$temporary_dir"' EXIT
git ls-files --cached --others --exclude-standard -z |
tar --null --files-from=- --create --file=- |
candidate_manifest=$temporary_dir/candidate-files
while IFS= read -r -d '' candidate_file; do
if [[ -e "$candidate_file" || -L "$candidate_file" ]]; then
printf '%s\0' "$candidate_file" >>"$candidate_manifest"
fi
done < <(git ls-files --cached --others --exclude-standard -z)
tar --null --files-from="$candidate_manifest" --create --file=- |
tar --extract --file=- --directory="$temporary_dir"
(
cd "$temporary_dir"
@@ -35,7 +40,12 @@ git ls-files --cached --others --exclude-standard -z |
diff -u go.mod "$temporary_dir/go.mod"
diff -u go.sum "$temporary_dir/go.sum"
mapfile -d '' go_files < <(git ls-files --cached --others --exclude-standard -z -- '*.go')
go_files=()
while IFS= read -r -d '' go_file; do
if [[ -f "$go_file" ]]; then
go_files+=("$go_file")
fi
done < <(git ls-files --cached --others --exclude-standard -z -- '*.go')
if ((${#go_files[@]})); then
unformatted=$(gofmt -l -- "${go_files[@]}")
if [[ -n "$unformatted" ]]; then
@@ -46,6 +56,9 @@ fi
git diff --check HEAD --
while IFS= read -r -d '' candidate_file; do
if [[ ! -f "$candidate_file" ]]; then
continue
fi
case "$candidate_file" in
*.go|*.md|*.sh|go.mod|go.sum|LICENSE|.gitignore|AGENTS.md)
if LC_ALL=C grep -nE '[[:blank:]]+$' -- "$candidate_file"; then

View File

@@ -7,7 +7,7 @@ type secretDependency struct {
dependency Path
}
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication) map[string]bool {
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool {
paths := make(map[string]Path)
finalPaths := make(map[string]*document.Node)
indexDocument(final, Path{}, finalPaths)
@@ -36,6 +36,16 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
}
secret := make(map[string]bool)
for _, tree := range append(projections, final) {
projected := make(map[string]*document.Node)
indexDocument(tree, Path{}, projected)
for pointer, node := range projected {
addSecretPath(paths, pointer)
if node.Sensitive {
secret[pointer] = true
}
}
}
for pointer, path := range paths {
if root, ok := schemaSecretRoot(schema, path); ok {
secret[root.String()] = true
@@ -129,3 +139,15 @@ func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
}
return Path{}, false
}
// Dynamic type replacement can change classification even when projected text
// is equal. Validators must preserve this inspection metadata as well as values.
func projectedSensitivity(root *document.Node) map[string]Path {
result := make(map[string]Path)
walkDocument(root, Path{}, func(path Path, node *document.Node) {
if node.Sensitive {
result[path.String()] = path.clone()
}
})
return result
}

View File

@@ -1,6 +1,7 @@
package configkit
import (
"cmp"
"encoding/json"
"fmt"
"io"
@@ -139,7 +140,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
return
}
keys := value.MapKeys()
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmpString(left.String(), right.String()) })
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) })
for _, key := range keys {
entry := reflect.New(value.Type().Elem()).Elem()
entry.Set(value.MapIndex(key))
@@ -167,7 +168,7 @@ func secretDiagnostic(code Code, path Path, message string, input *document.Node
func changedSecretStates(root reflect.Value, descriptor *typeDescriptor, lookups map[string]secretLookupResult) []Path {
var changed []Path
collectChangedSecretStates(root, descriptor, Path{}, lookups, &changed)
slices.SortFunc(changed, func(left, right Path) int { return cmpString(left.String(), right.String()) })
slices.SortFunc(changed, func(left, right Path) int { return cmp.Compare(left.String(), right.String()) })
return changed
}
@@ -198,7 +199,7 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
return
}
keys := value.MapKeys()
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmpString(left.String(), right.String()) })
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) })
for _, key := range keys {
collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed)
}
@@ -211,3 +212,14 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
}
}
}
func cloneSecretReferences(values map[string]string) map[string]string {
if values == nil {
return nil
}
cloned := make(map[string]string, len(values))
for path, name := range values {
cloned[path] = name
}
return cloned
}

63
snapshot.go Normal file
View 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)
})
}

View File

@@ -125,10 +125,7 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
}
info := &SourceInfo{ID: sourceID, Label: source.label, Layer: LayerBase}
var (
input io.Reader
close func() error
)
var input io.Reader
switch source.kind {
case sourceFile:
absolute, err := filepath.Abs(source.path)
@@ -136,31 +133,16 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceRead, sourceID, "source path could not be resolved")}
}
info.Path = filepath.Clean(absolute)
file, err := os.Open(source.path)
if err != nil {
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
}
close = file.Close
stat, err := file.Stat()
if err != nil {
_ = file.Close()
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
}
if !stat.Mode().IsRegular() {
_ = file.Close()
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")}
}
canonical, err := filepath.EvalSymlinks(info.Path)
if err != nil {
_ = file.Close()
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
}
info.Path = canonical
if stat.Size() > limit {
_ = file.Close()
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "source exceeds the configured byte limit")}
data, failure := readRegularFile(canonical, os.Stat, os.Open, limit)
if failure != nil {
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
}
input = file
return sourceRead{data: bytes.Clone(data), info: info}, nil
case sourceBytes:
input = bytes.NewReader(source.data)
case sourceReader:
@@ -171,16 +153,9 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
default:
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
}
if close != nil {
defer func() { _ = close() }()
}
data, err := io.ReadAll(io.LimitReader(input, limit+1))
if err != nil {
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
}
if int64(len(data)) > limit {
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "source exceeds the configured byte limit")}
data, failure := readBoundedSource(input, limit)
if failure != nil {
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
}
return sourceRead{data: bytes.Clone(data), info: info}, nil
}

71
source_fifo_test.go Normal file
View 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
View 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
View 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 }

View File

@@ -52,7 +52,7 @@ func TestFileSourcesReopenAndClassifyFailures(t *testing.T) {
}
source := File(path)
first, diagnostics := readSource(source, "source-1", defaultSourceLimits())
if len(diagnostics) != 0 || string(first.data) != "value: first\n" || first.info.Path != path {
if len(diagnostics) != 0 || string(first.data) != "value: first\n" || first.info.Path != canonicalTestPath(t, path) || first.info.Label != path {
t.Fatalf("first read = %#v, %#v", first, diagnostics)
}
if err := os.WriteFile(path, []byte("value: second\n"), 0o600); err != nil {

View File

@@ -109,9 +109,7 @@ func runValidation[T any](configuration *T, validator func(*T, *Validation), sch
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path))
return canonicalDiagnostics(state.diagnostics)
}
for _, delta := range compareDocumentTrees(before, after) {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(delta.path))
}
state.diagnostics = append(state.diagnostics, validationValueMutations(before, after)...)
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
}
@@ -166,3 +164,71 @@ func nearestDocumentLocation(root *document.Node, path Path) (document.Location,
func locationKnown(location document.Location) bool {
return location.SourceID != "" || location.Line != 0 || location.Column != 0
}
// validationValueMutations combines value and sensitivity changes; resolved
// secret state is audited separately because it never enters projected trees.
func validationValueMutations(before, after *document.Node) []Diagnostic {
var diagnostics []Diagnostic
for _, delta := range compareConfigurationValues(before, after) {
diagnostics = append(diagnostics, validationMutationDiagnostic(delta.path))
}
beforeSensitivity := projectedSensitivity(before)
afterSensitivity := projectedSensitivity(after)
for pointer, path := range beforeSensitivity {
if _, exists := afterSensitivity[pointer]; !exists {
diagnostics = append(diagnostics, validationMutationDiagnostic(path))
}
}
for pointer, path := range afterSensitivity {
if _, exists := beforeSensitivity[pointer]; !exists {
diagnostics = append(diagnostics, validationMutationDiagnostic(path))
}
}
return diagnostics
}
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
if rule == nil {
return nil
}
version := Version{}
node, present := documentAtPath(root, rule.Path)
if present {
version.Present = true
switch node.Kind {
case document.Null:
version.Null = true
case document.String:
version.Text = node.Text
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
View 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
}
}